@learnpack/learnpack 5.0.364 → 5.0.366

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.
@@ -25,6 +25,7 @@ const dotenv = require("dotenv");
25
25
  const creatorUtilities_1 = require("../utils/creatorUtilities");
26
26
  const lessonRecovery_1 = require("../utils/lessonRecovery");
27
27
  const generationRetry_1 = require("../utils/generationRetry");
28
+ const continueGeneration_1 = require("../utils/continueGeneration");
28
29
  // import { handleAssetCreation } from "./publish"
29
30
  const axios_1 = require("axios");
30
31
  const FormData = require("form-data");
@@ -1518,6 +1519,28 @@ class ServeCommand extends SessionCommand_1.default {
1518
1519
  if (!exercise.id || !exercise.title) {
1519
1520
  throw new errorHandler_1.ValidationError(`Lesson "${lessonUid}" is missing required properties (id or title)`);
1520
1521
  }
1522
+ const language = syllabus.courseInfo.language || "en";
1523
+ const previousStatus = exercise.status;
1524
+ // A second click, or a retry while the job still runs, would start
1525
+ // another paid job for the same lesson.
1526
+ if (previousStatus === "GENERATING") {
1527
+ const jobId = (0, continueGeneration_1.currentJobId)(exercise, language);
1528
+ let job = null;
1529
+ if (jobId) {
1530
+ try {
1531
+ job = await (0, rigoActions_1.getCompletionJob)(rigoToken, jobId, {
1532
+ timeoutMs: 5000,
1533
+ });
1534
+ }
1535
+ catch (error) {
1536
+ console.error(`Could not check job ${jobId}:`, error);
1537
+ }
1538
+ }
1539
+ if ((0, continueGeneration_1.isGenerationRunning)(exercise, language, job, Date.now())) {
1540
+ console.log(`Lesson ${lessonUid} is already being generated (job ${jobId}), not starting another one`);
1541
+ throw new errorHandler_1.ConflictError(`Lesson "${exercise.title}" is already being generated`, { code: "LESSON_ALREADY_GENERATING", completionId: jobId });
1542
+ }
1543
+ }
1521
1544
  // previous exercise
1522
1545
  let previousReadme = "---";
1523
1546
  const previousExercise = syllabus.lessons[exerciseIndex - 1];
@@ -1543,7 +1566,7 @@ class ServeCommand extends SessionCommand_1.default {
1543
1566
  // A new job by the user, so the automatic retries start over.
1544
1567
  syllabus.lessons[exerciseIndex].status = "GENERATING";
1545
1568
  syllabus.lessons[exerciseIndex].translations = {
1546
- [syllabus.courseInfo.language || "en"]: startedJob(completionId),
1569
+ [language]: startedJob(completionId),
1547
1570
  };
1548
1571
  console.log("Lesson", syllabus.lessons[exerciseIndex]);
1549
1572
  if (syllabus.feedback && typeof syllabus.feedback === "string") {
@@ -1552,7 +1575,7 @@ class ServeCommand extends SessionCommand_1.default {
1552
1575
  else {
1553
1576
  syllabus.feedback = feedback;
1554
1577
  }
1555
- syllabus.generationMode = mode;
1578
+ syllabus.generationMode = (0, continueGeneration_1.resolveGenerationMode)(previousStatus, syllabus.generationMode, mode);
1556
1579
  await saveSyllabus(courseSlug, syllabus, bucket);
1557
1580
  });
1558
1581
  res.json({ status: "SUCCESS" });
@@ -3763,9 +3786,12 @@ class ServeCommand extends SessionCommand_1.default {
3763
3786
  await uploadInitialReadme(bucket, exSlug, targetDir, syllabus.courseInfo);
3764
3787
  }
3765
3788
  const sidebar = await (0, sidebar_1.createInitialSidebar)(syllabus.lessons.map(lesson => (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title)), syllabus.courseInfo.language);
3789
+ const language = syllabus.courseInfo.language || "en";
3766
3790
  const initialSyllabus = Object.assign(Object.assign({}, syllabus), { lessons: syllabus.lessons.map((lesson, index) => {
3767
3791
  if (index < 1) {
3768
- return Object.assign(Object.assign({}, lesson), { generated: false, status: "GENERATING" });
3792
+ // Its start time, so a retry right away is refused even before
3793
+ // the job id is saved below.
3794
+ return Object.assign(Object.assign({}, lesson), { generated: false, status: "GENERATING", translations: { [language]: startedJob(0) } });
3769
3795
  }
3770
3796
  return Object.assign(Object.assign({}, lesson), { generated: false, status: "PENDING" });
3771
3797
  }) });
@@ -3773,17 +3799,16 @@ class ServeCommand extends SessionCommand_1.default {
3773
3799
  await (0, bucketIo_1.uploadFileToBucket)(bucket, JSON.stringify(sidebar), `${tutorialDir}/.learn/sidebar.json`);
3774
3800
  const firstLesson = syllabus.lessons[0];
3775
3801
  const lastResult = "---";
3776
- // Use new two-phase generation workflow
3777
- const completionId = await startInitialContentGeneration(rigoToken, syllabus.lessons, syllabus.courseInfo, firstLesson, courseSlug, syllabus.courseInfo.purpose, lastResult);
3778
- if (firstLesson) {
3779
- firstLesson.translations = {
3780
- [syllabus.courseInfo.language || "en"]: {
3781
- completionId,
3782
- startedAt: Date.now(),
3783
- completedAt: 0,
3784
- },
3785
- };
3786
- }
3802
+ // In the course queue, so the webhook of this job waits until its id
3803
+ // is saved. The id was never saved before, so the first lesson had no
3804
+ // protection against a second job.
3805
+ await serializeByCourse(courseSlug, async () => {
3806
+ // Use new two-phase generation workflow
3807
+ const completionId = await startInitialContentGeneration(rigoToken, syllabus.lessons, syllabus.courseInfo, firstLesson, courseSlug, syllabus.courseInfo.purpose, lastResult);
3808
+ await patchLesson(courseSlug, firstLesson.uid, bucket, lesson => {
3809
+ lesson.translations = { [language]: startedJob(completionId) };
3810
+ });
3811
+ });
3787
3812
  await createInitialReadme(JSON.stringify(syllabus.courseInfo), courseSlug, rigoToken);
3788
3813
  return res.json({
3789
3814
  message: "Course generation started",
@@ -12,11 +12,10 @@ const sidebarGenerator_1 = require("../../utils/sidebarGenerator");
12
12
  const defaults_1 = require("./defaults");
13
13
  const exercise_1 = require("./exercise");
14
14
  const file_1 = require("../file");
15
+ const ideReleases_1 = require("../ideReleases");
15
16
  /* exercise folder name standard */
16
17
  const execAsync = (0, util_1.promisify)(child_process_1.exec);
17
18
  // eslint-disable-next-line
18
- const fetch = require("node-fetch");
19
- // eslint-disable-next-line
20
19
  const chalk = require("chalk");
21
20
  /* exercise folder name standard */
22
21
  /**
@@ -186,9 +185,7 @@ exports.default = async ({ grading, mode, disableGrading, version, }) => {
186
185
  configObj.config.editor.version = version;
187
186
  else if (configObj.config.editor.version === null) {
188
187
  console_1.default.debug("Config version not found, downloading default.");
189
- const resp = await fetch("https://raw.githubusercontent.com/learnpack/ide/master/package.json");
190
- const packageJSON = await resp.json();
191
- configObj.config.editor.version = packageJSON.version || "5.0.0";
188
+ configObj.config.editor.version = await (0, ideReleases_1.getLatestEditorVersion)();
192
189
  }
193
190
  configObj.config.dirPath = "./" + confPath.base;
194
191
  configObj.config.exercisesPath = getExercisesPath(confPath.base) || "./";
@@ -9,6 +9,7 @@ const targz = require("targz");
9
9
  const console_1 = require("../utils/console");
10
10
  const https = require("https");
11
11
  const errors_1 = require("../utils/errors");
12
+ const ideReleases_1 = require("./ideReleases");
12
13
  // eslint-disable-next-line
13
14
  const fetch = require("node-fetch");
14
15
  const decompress = (sourcePath, destinationPath) => new Promise((resolve, reject) => {
@@ -30,54 +31,22 @@ const decompress = (sourcePath, destinationPath) => new Promise((resolve, reject
30
31
  exports.decompress = decompress;
31
32
  const downloadEditor = async (version, destination) => {
32
33
  console_1.default.info("Downloading the LearnPack coding UI, this may take a minute...");
33
- if (!version) {
34
- const res = await fetch("https://raw.githubusercontent.com/learnpack/ide/master/package.json");
35
- const json = await res.json();
36
- version = json.version || "4.0.0";
37
- if (!version)
38
- throw (0, errors_1.InternalError)(`Coding Editor version was not found on learnpack repository, check the config.editor.version property on learn.json`);
34
+ if (!version)
35
+ version = await (0, ideReleases_1.getLatestEditorVersion)();
36
+ const [cloudTags, githubTags] = await Promise.all([
37
+ (0, ideReleases_1.fetchTagList)(ideReleases_1.IDE_CLOUDFRONT_MANIFEST),
38
+ (0, ideReleases_1.fetchTagList)(ideReleases_1.IDE_GITHUB_MANIFEST),
39
+ ]);
40
+ let release = (0, ideReleases_1.resolveEditorRelease)(version, cloudTags, githubTags);
41
+ if (!release) {
42
+ console_1.default.debug(`No IDE version found for ${version} in the versions manifests, defaulting to version 4.0.2`);
43
+ release = { version: "4.0.2", source: "github" };
39
44
  }
40
- const [major, minor] = version.split(".");
41
- const versionPrefix = `${major}.${minor}`;
42
- let tags;
43
- try {
44
- const tagsRes = await fetch("https://raw.githubusercontent.com/learnpack/ide/master/versions.json");
45
- tags = await tagsRes.json();
46
- if (!Array.isArray(tags)) {
47
- throw new TypeError(`Invalid versions.json format`);
48
- }
49
- }
50
- catch (error) {
51
- console_1.default.debug("Error reading versions.json, defaulting to version 4.0.2", error);
52
- version = "4.0.2";
53
- tags = [`learnpack-${version}.tar.gz`];
54
- }
55
- let matchingTags;
56
- try {
57
- if (tags.length === 0 || !tags)
58
- throw (0, errors_1.InternalError)(`No found tags in versions.json ${tags}`);
59
- matchingTags = tags
60
- .filter(tag => tag.includes(`learnpack-${versionPrefix}`))
61
- .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
62
- if (matchingTags.length === 0)
63
- throw (0, errors_1.InternalError)(`No matching version found for prefix ${versionPrefix} in the versions.json`);
64
- }
65
- catch (error) {
66
- console_1.default.debug("Error processing tags, defaulting to version 4.0.2", error);
67
- version = "4.0.2";
68
- matchingTags = [`learnpack-${version}.tar.gz`];
69
- }
70
- const latestVersion = matchingTags[0]
71
- .replace("learnpack-", "")
72
- .replace(".tar.gz", "");
73
- const versionNumber = parseInt(latestVersion.split(".")[0]);
74
- let url = `https://github.com/learnpack/coding-ide/blob/${latestVersion}/dist`;
75
- if (versionNumber > 2)
76
- url = `https://github.com/learnpack/ide/blob/master/bin/learnpack-${latestVersion}.tar.gz`;
77
- const resp2 = await fetch(url, { method: "HEAD" });
78
- if (!resp2.ok)
79
- throw (0, errors_1.InternalError)(`Coding Editor ${latestVersion} was not found on learnpack repository, check the config.editor.version property on learn.json`);
80
- url += versionNumber > 2 ? `?raw=true` : `/app.tar.gz?raw=true`;
45
+ const url = (0, ideReleases_1.editorTarballUrl)(release.version, release.source);
46
+ const resp = await fetch(url, { method: "HEAD", timeout: ideReleases_1.FETCH_TIMEOUT_MS });
47
+ if (!resp.ok)
48
+ throw (0, errors_1.InternalError)(`Coding Editor ${release.version} was not found on ${release.source}, check the config.editor.version property on learn.json`);
49
+ console_1.default.debug(`Using IDE ${release.version} from ${release.source}`);
81
50
  return (0, exports.download)(url, destination);
82
51
  };
83
52
  exports.downloadEditor = downloadEditor;
@@ -92,7 +61,7 @@ const download = (url, dest) => {
92
61
  });
93
62
  file.on("error", err => {
94
63
  file.close();
95
- if (err.name === "EEXIST") {
64
+ if (err.code === "EEXIST") {
96
65
  console_1.default.debug("File already exists");
97
66
  resolve("File already exists");
98
67
  }
@@ -103,11 +72,10 @@ const download = (url, dest) => {
103
72
  });
104
73
  response.pipe(file);
105
74
  }
106
- else if (response.statusCode === 302 || response.statusCode === 301) {
107
- // Console.debug("Servers redirected to "+response.headers.location)
75
+ else if ([301, 302, 303, 307, 308].includes(response.statusCode || 0)) {
108
76
  // Recursively follow redirects, only a 200 will resolve.
109
77
  if (response.headers.location) {
110
- (0, exports.download)(response.headers.location, dest)
78
+ (0, exports.download)(new URL(response.headers.location, url).toString(), dest)
111
79
  .then(() => resolve(/* */ ""))
112
80
  .catch(error => {
113
81
  console_1.default.error(error);
@@ -0,0 +1,24 @@
1
+ export declare const IDE_CLOUDFRONT_BASE = "https://d3a7w3e3g8ydll.cloudfront.net/learnpack/ide";
2
+ export declare const IDE_CLOUDFRONT_MANIFEST = "https://d3a7w3e3g8ydll.cloudfront.net/learnpack/ide/versions.json";
3
+ export declare const IDE_GITHUB_MANIFEST = "https://raw.githubusercontent.com/learnpack/ide/master/versions.json";
4
+ export declare const IDE_GITHUB_PACKAGE = "https://raw.githubusercontent.com/learnpack/ide/master/package.json";
5
+ export declare const FETCH_TIMEOUT_MS = 10000;
6
+ export type TEditorSource = "cloudfront" | "github";
7
+ export interface IEditorRelease {
8
+ version: string;
9
+ source: TEditorSource;
10
+ }
11
+ interface IParsedVersion {
12
+ version: string;
13
+ major: number;
14
+ minor: number;
15
+ patch: number;
16
+ }
17
+ export declare const parseVersion: (version: string) => IParsedVersion | null;
18
+ export declare const parseTag: (tag: string) => IParsedVersion | null;
19
+ export declare const highestVersion: (tags: string[] | null) => string | null;
20
+ export declare const resolveEditorRelease: (requested: string, cloudTags: string[] | null, githubTags: string[] | null) => IEditorRelease | null;
21
+ export declare const editorTarballUrl: (version: string, source: TEditorSource) => string;
22
+ export declare const fetchTagList: (url: string) => Promise<string[] | null>;
23
+ export declare const getLatestEditorVersion: () => Promise<string>;
24
+ export {};
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getLatestEditorVersion = exports.fetchTagList = exports.editorTarballUrl = exports.resolveEditorRelease = exports.highestVersion = exports.parseTag = exports.parseVersion = exports.FETCH_TIMEOUT_MS = exports.IDE_GITHUB_PACKAGE = exports.IDE_GITHUB_MANIFEST = exports.IDE_CLOUDFRONT_MANIFEST = exports.IDE_CLOUDFRONT_BASE = void 0;
4
+ const console_1 = require("../utils/console");
5
+ // eslint-disable-next-line
6
+ const fetch = require("node-fetch");
7
+ // IDE tarballs published from 5.0.417 on live on CloudFront; older ones stay in the
8
+ // learnpack/ide GitHub repo, whose versions.json is frozen. Both lists are merged.
9
+ exports.IDE_CLOUDFRONT_BASE = "https://d3a7w3e3g8ydll.cloudfront.net/learnpack/ide";
10
+ exports.IDE_CLOUDFRONT_MANIFEST = `${exports.IDE_CLOUDFRONT_BASE}/versions.json`;
11
+ exports.IDE_GITHUB_MANIFEST = "https://raw.githubusercontent.com/learnpack/ide/master/versions.json";
12
+ exports.IDE_GITHUB_PACKAGE = "https://raw.githubusercontent.com/learnpack/ide/master/package.json";
13
+ exports.FETCH_TIMEOUT_MS = 10000;
14
+ const DEFAULT_EDITOR_VERSION = "5.0.0";
15
+ const parseVersion = (version) => {
16
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
17
+ if (!match)
18
+ return null;
19
+ return {
20
+ version: match.slice(1, 4).join("."),
21
+ major: Number(match[1]),
22
+ minor: Number(match[2]),
23
+ patch: Number(match[3]),
24
+ };
25
+ };
26
+ exports.parseVersion = parseVersion;
27
+ // "learnpack-5.0.417.tar.gz" -> parsed 5.0.417; anything else -> null
28
+ const parseTag = (tag) => {
29
+ const match = /^learnpack-(\d+\.\d+\.\d+)\.tar\.gz$/.exec(tag);
30
+ return match ? (0, exports.parseVersion)(match[1]) : null;
31
+ };
32
+ exports.parseTag = parseTag;
33
+ const compareVersions = (a, b) => a.major - b.major || a.minor - b.minor || a.patch - b.patch;
34
+ const parseTags = (tags) => (tags || []).map(exports.parseTag).filter((v) => v !== null);
35
+ // Highest version listed in a manifest, or null when there is none.
36
+ const highestVersion = (tags) => {
37
+ const sorted = parseTags(tags).sort(compareVersions);
38
+ return sorted.length > 0 ? sorted[sorted.length - 1].version : null;
39
+ };
40
+ exports.highestVersion = highestVersion;
41
+ // Picks the tarball to download for the requested version. Only major.minor of the
42
+ // request is used and the highest patch available wins, as the CLI always did.
43
+ // The source is CloudFront when the version is in its manifest, GitHub otherwise.
44
+ const resolveEditorRelease = (requested, cloudTags, githubTags) => {
45
+ const [major, minor] = requested.split(".").map(part => Number(part));
46
+ if (!Number.isInteger(major) || !Number.isInteger(minor))
47
+ return null;
48
+ const cloud = parseTags(cloudTags);
49
+ const candidates = [...cloud, ...parseTags(githubTags)]
50
+ .filter(v => v.major === major && v.minor === minor)
51
+ .sort(compareVersions);
52
+ if (candidates.length === 0)
53
+ return null;
54
+ const { version } = candidates[candidates.length - 1];
55
+ const source = cloud.some(v => v.version === version) ?
56
+ "cloudfront" :
57
+ "github";
58
+ return { version, source };
59
+ };
60
+ exports.resolveEditorRelease = resolveEditorRelease;
61
+ const editorTarballUrl = (version, source) => {
62
+ if (source === "cloudfront") {
63
+ return `${exports.IDE_CLOUDFRONT_BASE}/learnpack-${version}.tar.gz`;
64
+ }
65
+ const major = Number.parseInt(version.split(".")[0], 10);
66
+ return major > 2 ?
67
+ `https://github.com/learnpack/ide/blob/master/bin/learnpack-${version}.tar.gz?raw=true` :
68
+ `https://github.com/learnpack/coding-ide/blob/${version}/dist/app.tar.gz?raw=true`;
69
+ };
70
+ exports.editorTarballUrl = editorTarballUrl;
71
+ // Fetches a versions manifest. Never throws: any failure returns null.
72
+ const fetchTagList = async (url) => {
73
+ try {
74
+ const res = await fetch(url, { timeout: exports.FETCH_TIMEOUT_MS });
75
+ if (!res.ok) {
76
+ console_1.default.debug(`Could not read ${url}: ${res.status} ${res.statusText}`);
77
+ return null;
78
+ }
79
+ const json = await res.json();
80
+ if (!Array.isArray(json) || !json.every(tag => typeof tag === "string")) {
81
+ console_1.default.debug(`Invalid versions manifest format at ${url}`);
82
+ return null;
83
+ }
84
+ return json;
85
+ }
86
+ catch (error) {
87
+ console_1.default.debug(`Could not read ${url}`, error);
88
+ return null;
89
+ }
90
+ };
91
+ exports.fetchTagList = fetchTagList;
92
+ // Latest published IDE version: CloudFront manifest, then GitHub package.json, then a default.
93
+ const getLatestEditorVersion = async () => {
94
+ const fromCloud = (0, exports.highestVersion)(await (0, exports.fetchTagList)(exports.IDE_CLOUDFRONT_MANIFEST));
95
+ if (fromCloud)
96
+ return fromCloud;
97
+ try {
98
+ const res = await fetch(exports.IDE_GITHUB_PACKAGE, { timeout: exports.FETCH_TIMEOUT_MS });
99
+ if (res.ok) {
100
+ const json = await res.json();
101
+ if (typeof (json === null || json === void 0 ? void 0 : json.version) === "string" && (0, exports.parseVersion)(json.version)) {
102
+ return json.version;
103
+ }
104
+ }
105
+ }
106
+ catch (error) {
107
+ console_1.default.debug("Could not read the IDE package.json from GitHub", error);
108
+ }
109
+ return DEFAULT_EDITOR_VERSION;
110
+ };
111
+ exports.getLatestEditorVersion = getLatestEditorVersion;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Rules for `POST /actions/continue-generating`, the request behind the
3
+ * creator's continue and retry buttons. Without them, every extra click starts
4
+ * another paid job for a lesson that is already being generated.
5
+ */
6
+ /**
7
+ * How long a PENDING job counts as running. Each phase of a lesson takes 1 to
8
+ * 3 minutes, so a job PENDING for longer is most likely stuck, for example
9
+ * after a worker restart. A stuck job holds the whole course, because the next
10
+ * lesson only starts after this one finishes. Replacing a slow job that was
11
+ * still alive only costs one extra job: serve ignores the older job's webhook.
12
+ */
13
+ export declare const RUNNING_JOB_MAX_AGE_MS: number;
14
+ /**
15
+ * How long a finished job counts as running. Rigobot saves the result before
16
+ * it sends the webhook, and serve may still be handling it.
17
+ */
18
+ export declare const WEBHOOK_GRACE_MS: number;
19
+ /**
20
+ * How long a lesson counts as running when its job cannot be checked: it has
21
+ * no job id yet, or Rigobot did not answer.
22
+ */
23
+ export declare const UNCHECKED_JOB_GRACE_MS: number;
24
+ export type GenerationMode = "next-three" | "continue-with-all";
25
+ type LessonState = {
26
+ status?: string;
27
+ translations?: {
28
+ [language: string]: {
29
+ completionId?: number;
30
+ startedAt?: number;
31
+ };
32
+ };
33
+ };
34
+ /** The fields of a Rigobot completion job these rules read. */
35
+ export type JobState = {
36
+ status?: string;
37
+ ended_at?: string | null;
38
+ };
39
+ /**
40
+ * The job to check before starting another one.
41
+ * @param lesson - The lesson as saved in the syllabus.
42
+ * @param language - The course language.
43
+ * @returns The id of the job the lesson waits for, or null if it has none.
44
+ */
45
+ export declare const currentJobId: (lesson: LessonState, language: string) => number | null;
46
+ /**
47
+ * Whether a lesson is still being generated, so a new job must not start.
48
+ * @param lesson - The lesson as saved in the syllabus.
49
+ * @param language - The course language.
50
+ * @param job - Its job as Rigobot reports it, or null if it was not checked.
51
+ * @param now - The current time in milliseconds.
52
+ * @returns True when the lesson must wait for its current job.
53
+ */
54
+ export declare const isGenerationRunning: (lesson: LessonState, language: string, job: JobState | null, now: number) => boolean;
55
+ /**
56
+ * The generation mode to save. The user only picks a mode on a PENDING
57
+ * lesson. The retry buttons offer no choice, so a retry keeps the mode of the
58
+ * course instead of resetting it.
59
+ * @param lessonStatus - The status of the lesson before this request.
60
+ * @param current - The mode saved in the syllabus.
61
+ * @param requested - The mode in the request.
62
+ * @returns The mode to save, or undefined when the course has none.
63
+ */
64
+ export declare const resolveGenerationMode: (lessonStatus: string | undefined, current: unknown, requested: unknown) => GenerationMode | undefined;
65
+ export {};
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ /**
3
+ * Rules for `POST /actions/continue-generating`, the request behind the
4
+ * creator's continue and retry buttons. Without them, every extra click starts
5
+ * another paid job for a lesson that is already being generated.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.resolveGenerationMode = exports.isGenerationRunning = exports.currentJobId = exports.UNCHECKED_JOB_GRACE_MS = exports.WEBHOOK_GRACE_MS = exports.RUNNING_JOB_MAX_AGE_MS = void 0;
9
+ /**
10
+ * How long a PENDING job counts as running. Each phase of a lesson takes 1 to
11
+ * 3 minutes, so a job PENDING for longer is most likely stuck, for example
12
+ * after a worker restart. A stuck job holds the whole course, because the next
13
+ * lesson only starts after this one finishes. Replacing a slow job that was
14
+ * still alive only costs one extra job: serve ignores the older job's webhook.
15
+ */
16
+ exports.RUNNING_JOB_MAX_AGE_MS = 5 * 60 * 1000;
17
+ /**
18
+ * How long a finished job counts as running. Rigobot saves the result before
19
+ * it sends the webhook, and serve may still be handling it.
20
+ */
21
+ exports.WEBHOOK_GRACE_MS = 90 * 1000;
22
+ /**
23
+ * How long a lesson counts as running when its job cannot be checked: it has
24
+ * no job id yet, or Rigobot did not answer.
25
+ */
26
+ exports.UNCHECKED_JOB_GRACE_MS = 2 * 60 * 1000;
27
+ /**
28
+ * The job to check before starting another one.
29
+ * @param lesson - The lesson as saved in the syllabus.
30
+ * @param language - The course language.
31
+ * @returns The id of the job the lesson waits for, or null if it has none.
32
+ */
33
+ const currentJobId = (lesson, language) => {
34
+ var _a, _b;
35
+ const id = (_b = (_a = lesson.translations) === null || _a === void 0 ? void 0 : _a[language]) === null || _b === void 0 ? void 0 : _b.completionId;
36
+ return typeof id === "number" && id > 0 ? id : null;
37
+ };
38
+ exports.currentJobId = currentJobId;
39
+ /**
40
+ * Whether a lesson is still being generated, so a new job must not start.
41
+ * @param lesson - The lesson as saved in the syllabus.
42
+ * @param language - The course language.
43
+ * @param job - Its job as Rigobot reports it, or null if it was not checked.
44
+ * @param now - The current time in milliseconds.
45
+ * @returns True when the lesson must wait for its current job.
46
+ */
47
+ const isGenerationRunning = (lesson, language, job, now) => {
48
+ var _a, _b;
49
+ if (lesson.status !== "GENERATING")
50
+ return false;
51
+ const startedAt = (_b = (_a = lesson.translations) === null || _a === void 0 ? void 0 : _a[language]) === null || _b === void 0 ? void 0 : _b.startedAt;
52
+ const startedAgo = typeof startedAt === "number" && startedAt > 0 ?
53
+ now - startedAt :
54
+ Number.POSITIVE_INFINITY;
55
+ if (!job)
56
+ return startedAgo < exports.UNCHECKED_JOB_GRACE_MS;
57
+ if (job.status === "PENDING")
58
+ return startedAgo < exports.RUNNING_JOB_MAX_AGE_MS;
59
+ // A finished job. Its webhook may still be on its way to serve.
60
+ const endedAt = job.ended_at ? Date.parse(job.ended_at) : Number.NaN;
61
+ if (Number.isNaN(endedAt))
62
+ return startedAgo < exports.UNCHECKED_JOB_GRACE_MS;
63
+ return now - endedAt < exports.WEBHOOK_GRACE_MS;
64
+ };
65
+ exports.isGenerationRunning = isGenerationRunning;
66
+ /**
67
+ * The generation mode to save. The user only picks a mode on a PENDING
68
+ * lesson. The retry buttons offer no choice, so a retry keeps the mode of the
69
+ * course instead of resetting it.
70
+ * @param lessonStatus - The status of the lesson before this request.
71
+ * @param current - The mode saved in the syllabus.
72
+ * @param requested - The mode in the request.
73
+ * @returns The mode to save, or undefined when the course has none.
74
+ */
75
+ const resolveGenerationMode = (lessonStatus, current, requested) => {
76
+ const isMode = (mode) => mode === "next-three" || mode === "continue-with-all";
77
+ if (isMode(requested) && (lessonStatus === "PENDING" || !isMode(current))) {
78
+ return requested;
79
+ }
80
+ return isMode(current) ? current : undefined;
81
+ };
82
+ exports.resolveGenerationMode = resolveGenerationMode;
@@ -26,6 +26,7 @@ export type TCompletionJob = {
26
26
  status: "PENDING" | "SUCCESS" | "ERROR" | string;
27
27
  status_text?: string | null;
28
28
  parsed?: Record<string, any> | null;
29
+ ended_at?: string | null;
29
30
  };
30
31
  type TTranslateAndWaitOptions = {
31
32
  /** How often the job status is checked while it is still running. */
@@ -35,7 +36,9 @@ type TTranslateAndWaitOptions = {
35
36
  /** Called after every successful poll, useful to keep a heartbeat alive. */
36
37
  onPoll?: (job: TCompletionJob) => void | Promise<void>;
37
38
  };
38
- export declare const getCompletionJob: (token: string, completionId: number | string) => Promise<TCompletionJob>;
39
+ export declare const getCompletionJob: (token: string, completionId: number | string, options?: {
40
+ timeoutMs?: number;
41
+ }) => Promise<TCompletionJob>;
39
42
  export declare const translateExerciseAndWait: (token: string, inputs: TTranslateInputs, options?: TTranslateAndWaitOptions) => Promise<TCompletionJob>;
40
43
  type TGenerateCourseIntroductionInputs = {
41
44
  course_title: string;
@@ -114,12 +114,14 @@ const assertJobSucceeded = (job) => {
114
114
  }
115
115
  return job;
116
116
  };
117
- const getCompletionJob = async (token, completionId) => {
117
+ const getCompletionJob = async (token, completionId, options = {}) => {
118
118
  const response = await axios_1.default.get(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/${completionId}/`, {
119
119
  headers: {
120
120
  "Content-Type": "application/json",
121
121
  Authorization: "Token " + token,
122
122
  },
123
+ // No timeout by default: the translation polling waits as long as it needs.
124
+ timeout: options.timeoutMs,
123
125
  });
124
126
  return response.data;
125
127
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@learnpack/learnpack",
3
3
  "description": "Seamlessly build, sell and/or take interactive & auto-graded tutorials, start learning now or build a new tutorial to your audience.",
4
- "version": "5.0.364",
4
+ "version": "5.0.366",
5
5
  "author": "Alejandro Sanchez @alesanchezr",
6
6
  "contributors": [
7
7
  {