@learnpack/learnpack 5.0.355 → 5.0.357

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 (120) hide show
  1. package/lib/commands/serve.d.ts +0 -17
  2. package/lib/commands/serve.js +159 -129
  3. package/lib/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
  4. package/lib/creatorDist/assets/index-zrPponAn.css +1701 -0
  5. package/lib/creatorDist/index.html +2 -2
  6. package/lib/models/creator.d.ts +1 -1
  7. package/lib/services/ingest/adapters/gcsCourseStorage.d.ts +13 -0
  8. package/lib/services/ingest/adapters/gcsCourseStorage.js +58 -0
  9. package/lib/services/ingest/adapters/rigoUserTokenAuth.d.ts +15 -0
  10. package/lib/services/ingest/adapters/rigoUserTokenAuth.js +46 -0
  11. package/lib/services/ingest/adapters/rigobotPackageRegistry.d.ts +7 -0
  12. package/lib/services/ingest/adapters/rigobotPackageRegistry.js +119 -0
  13. package/lib/services/ingest/core/buildIngestConfig.d.ts +27 -0
  14. package/lib/services/ingest/core/buildIngestConfig.js +40 -0
  15. package/lib/services/ingest/core/buildInitialSyllabus.d.ts +24 -0
  16. package/lib/services/ingest/core/buildInitialSyllabus.js +104 -0
  17. package/lib/services/ingest/core/buildSidebar.d.ts +13 -0
  18. package/lib/services/ingest/core/buildSidebar.js +28 -0
  19. package/lib/services/ingest/core/coursePaths.d.ts +31 -0
  20. package/lib/services/ingest/core/coursePaths.js +50 -0
  21. package/lib/services/ingest/core/ingestCoursePackage.d.ts +52 -0
  22. package/lib/services/ingest/core/ingestCoursePackage.js +217 -0
  23. package/lib/services/ingest/core/normalizeLearnJson.d.ts +25 -0
  24. package/lib/services/ingest/core/normalizeLearnJson.js +29 -0
  25. package/lib/services/ingest/core/pairLessons.d.ts +29 -0
  26. package/lib/services/ingest/core/pairLessons.js +102 -0
  27. package/lib/services/ingest/core/planWrites.d.ts +44 -0
  28. package/lib/services/ingest/core/planWrites.js +59 -0
  29. package/lib/services/ingest/core/resolveSlug.d.ts +53 -0
  30. package/lib/services/ingest/core/resolveSlug.js +93 -0
  31. package/lib/services/ingest/core/types.d.ts +53 -0
  32. package/lib/services/ingest/core/types.js +2 -0
  33. package/lib/services/ingest/core/validatePackage.d.ts +38 -0
  34. package/lib/services/ingest/core/validatePackage.js +97 -0
  35. package/lib/services/ingest/createIngestService.d.ts +34 -0
  36. package/lib/services/ingest/createIngestService.js +38 -0
  37. package/lib/services/ingest/errors.d.ts +22 -0
  38. package/lib/services/ingest/errors.js +34 -0
  39. package/lib/services/ingest/http/errorMapping.d.ts +8 -0
  40. package/lib/services/ingest/http/errorMapping.js +40 -0
  41. package/lib/services/ingest/http/router.d.ts +32 -0
  42. package/lib/services/ingest/http/router.js +124 -0
  43. package/lib/services/ingest/http/zipPackageReader.d.ts +26 -0
  44. package/lib/services/ingest/http/zipPackageReader.js +62 -0
  45. package/lib/services/ingest/ports/courseStorage.d.ts +25 -0
  46. package/lib/services/ingest/ports/courseStorage.js +2 -0
  47. package/lib/services/ingest/ports/packageRegistry.d.ts +58 -0
  48. package/lib/services/ingest/ports/packageRegistry.js +2 -0
  49. package/lib/services/ingest/ports/requestAuthenticator.d.ts +25 -0
  50. package/lib/services/ingest/ports/requestAuthenticator.js +2 -0
  51. package/lib/utils/coursePackage/bucketIo.d.ts +23 -0
  52. package/lib/utils/coursePackage/bucketIo.js +36 -0
  53. package/lib/utils/coursePackage/learnJson.d.ts +26 -0
  54. package/lib/utils/coursePackage/learnJson.js +36 -0
  55. package/lib/utils/coursePackage/sidebar.d.ts +15 -0
  56. package/lib/utils/coursePackage/sidebar.js +26 -0
  57. package/lib/utils/creatorSocket.js +15 -21
  58. package/lib/utils/gcpCredentials.d.ts +18 -0
  59. package/lib/utils/gcpCredentials.js +27 -0
  60. package/lib/utils/rigoActions.d.ts +16 -0
  61. package/lib/utils/rigoActions.js +102 -1
  62. package/lib/utils/socketRegistry.d.ts +46 -0
  63. package/lib/utils/socketRegistry.js +83 -0
  64. package/package.json +1 -1
  65. package/src/commands/serve.ts +162 -114
  66. package/src/creator/README.md +63 -51
  67. package/src/creator/package-lock.json +1188 -8
  68. package/src/creator/package.json +5 -1
  69. package/src/creator/src/App.tsx +120 -54
  70. package/src/creator/src/components/FileUploader.tsx +1 -12
  71. package/src/creator/src/components/NotificationListener.tsx +1 -8
  72. package/src/creator/src/components/syllabus/SyllabusEditor.tsx +101 -36
  73. package/src/creator/src/locales/en.json +8 -0
  74. package/src/creator/src/locales/es.json +8 -0
  75. package/src/creator/src/utils/completionResult.test.ts +144 -0
  76. package/src/creator/src/utils/completionResult.ts +154 -0
  77. package/src/creator/src/utils/constants.ts +17 -4
  78. package/src/creator/src/utils/socket.test.ts +113 -0
  79. package/src/creator/src/utils/socket.ts +60 -37
  80. package/src/creator/src/utils/store.ts +84 -67
  81. package/src/creator/src/utils/useCompletionWatchdog.test.ts +99 -0
  82. package/src/creator/src/utils/useCompletionWatchdog.ts +37 -0
  83. package/src/creator/vitest.config.ts +16 -0
  84. package/src/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
  85. package/src/creatorDist/assets/index-zrPponAn.css +1701 -0
  86. package/src/creatorDist/index.html +2 -2
  87. package/src/models/creator.ts +1 -1
  88. package/src/services/ingest/adapters/gcsCourseStorage.ts +70 -0
  89. package/src/services/ingest/adapters/rigoUserTokenAuth.ts +65 -0
  90. package/src/services/ingest/adapters/rigobotPackageRegistry.ts +161 -0
  91. package/src/services/ingest/core/buildIngestConfig.ts +51 -0
  92. package/src/services/ingest/core/buildInitialSyllabus.ts +122 -0
  93. package/src/services/ingest/core/buildSidebar.ts +32 -0
  94. package/src/services/ingest/core/coursePaths.ts +59 -0
  95. package/src/services/ingest/core/ingestCoursePackage.ts +343 -0
  96. package/src/services/ingest/core/normalizeLearnJson.ts +47 -0
  97. package/src/services/ingest/core/pairLessons.ts +132 -0
  98. package/src/services/ingest/core/planWrites.ts +91 -0
  99. package/src/services/ingest/core/resolveSlug.ts +119 -0
  100. package/src/services/ingest/core/types.ts +57 -0
  101. package/src/services/ingest/core/validatePackage.ts +132 -0
  102. package/src/services/ingest/createIngestService.ts +66 -0
  103. package/src/services/ingest/errors.ts +45 -0
  104. package/src/services/ingest/http/errorMapping.ts +49 -0
  105. package/src/services/ingest/http/router.ts +156 -0
  106. package/src/services/ingest/http/zipPackageReader.ts +81 -0
  107. package/src/services/ingest/ports/courseStorage.ts +25 -0
  108. package/src/services/ingest/ports/packageRegistry.ts +60 -0
  109. package/src/services/ingest/ports/requestAuthenticator.ts +28 -0
  110. package/src/ui/_app/app.js +325 -325
  111. package/src/ui/app.tar.gz +0 -0
  112. package/src/utils/coursePackage/bucketIo.ts +46 -0
  113. package/src/utils/coursePackage/learnJson.ts +37 -0
  114. package/src/utils/coursePackage/sidebar.ts +28 -0
  115. package/src/utils/creatorSocket.ts +16 -22
  116. package/src/utils/gcpCredentials.ts +38 -0
  117. package/src/utils/rigoActions.ts +163 -0
  118. package/src/utils/socketRegistry.ts +85 -0
  119. package/lib/creatorDist/assets/index-CjddKHB_.css +0 -1
  120. package/src/creatorDist/assets/index-CjddKHB_.css +0 -1
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Which sockets are listening on which channel.
3
+ *
4
+ * Extracted from `creatorSocket.ts` so the bookkeeping can be tested without
5
+ * standing up a socket.io server. The registry deals in socket ids only; the
6
+ * socket objects themselves stay with the transport layer.
7
+ *
8
+ * The behaviour that matters is `removeSocket`: a disconnecting client has to
9
+ * disappear from **every** channel it joined. The notification map used to be
10
+ * skipped on disconnect, so it grew for the lifetime of the process and
11
+ * accumulated ids of sockets that were long gone.
12
+ */
13
+ export declare class SocketRegistry {
14
+ private readonly channels;
15
+ /**
16
+ * Adds a socket to a channel.
17
+ * @param channel - Channel key, such as a course slug or a notification id.
18
+ * @param socketId - The socket joining it.
19
+ */
20
+ register(channel: string, socketId: string): void;
21
+ /**
22
+ * Removes a socket from one channel, dropping the channel when it empties.
23
+ * @param channel - Channel to leave.
24
+ * @param socketId - The socket leaving it.
25
+ */
26
+ unregister(channel: string, socketId: string): void;
27
+ /**
28
+ * Removes a socket from every channel it joined.
29
+ *
30
+ * Called when the socket disconnects. Without it the registry keeps growing
31
+ * with ids that can never receive anything again.
32
+ * @param socketId - The socket that went away.
33
+ */
34
+ removeSocket(socketId: string): void;
35
+ /**
36
+ * The sockets currently listening on a channel.
37
+ * @param channel - Channel to look up.
38
+ * @returns Their socket ids, empty when nobody is listening.
39
+ */
40
+ listeners(channel: string): string[];
41
+ /**
42
+ * How many channels are being tracked. Exposed for tests and diagnostics.
43
+ * @returns The number of non-empty channels.
44
+ */
45
+ channelCount(): number;
46
+ }
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SocketRegistry = void 0;
4
+ /**
5
+ * Which sockets are listening on which channel.
6
+ *
7
+ * Extracted from `creatorSocket.ts` so the bookkeeping can be tested without
8
+ * standing up a socket.io server. The registry deals in socket ids only; the
9
+ * socket objects themselves stay with the transport layer.
10
+ *
11
+ * The behaviour that matters is `removeSocket`: a disconnecting client has to
12
+ * disappear from **every** channel it joined. The notification map used to be
13
+ * skipped on disconnect, so it grew for the lifetime of the process and
14
+ * accumulated ids of sockets that were long gone.
15
+ */
16
+ class SocketRegistry {
17
+ constructor() {
18
+ this.channels = new Map();
19
+ }
20
+ /**
21
+ * Adds a socket to a channel.
22
+ * @param channel - Channel key, such as a course slug or a notification id.
23
+ * @param socketId - The socket joining it.
24
+ */
25
+ register(channel, socketId) {
26
+ if (!channel || !socketId) {
27
+ return;
28
+ }
29
+ let listeners = this.channels.get(channel);
30
+ if (!listeners) {
31
+ listeners = new Set();
32
+ this.channels.set(channel, listeners);
33
+ }
34
+ listeners.add(socketId);
35
+ }
36
+ /**
37
+ * Removes a socket from one channel, dropping the channel when it empties.
38
+ * @param channel - Channel to leave.
39
+ * @param socketId - The socket leaving it.
40
+ */
41
+ unregister(channel, socketId) {
42
+ const listeners = this.channels.get(channel);
43
+ if (!listeners) {
44
+ return;
45
+ }
46
+ listeners.delete(socketId);
47
+ if (listeners.size === 0) {
48
+ this.channels.delete(channel);
49
+ }
50
+ }
51
+ /**
52
+ * Removes a socket from every channel it joined.
53
+ *
54
+ * Called when the socket disconnects. Without it the registry keeps growing
55
+ * with ids that can never receive anything again.
56
+ * @param socketId - The socket that went away.
57
+ */
58
+ removeSocket(socketId) {
59
+ for (const [channel, listeners] of this.channels) {
60
+ listeners.delete(socketId);
61
+ if (listeners.size === 0) {
62
+ this.channels.delete(channel);
63
+ }
64
+ }
65
+ }
66
+ /**
67
+ * The sockets currently listening on a channel.
68
+ * @param channel - Channel to look up.
69
+ * @returns Their socket ids, empty when nobody is listening.
70
+ */
71
+ listeners(channel) {
72
+ const listeners = this.channels.get(channel);
73
+ return listeners ? [...listeners] : [];
74
+ }
75
+ /**
76
+ * How many channels are being tracked. Exposed for tests and diagnostics.
77
+ * @returns The number of non-empty channels.
78
+ */
79
+ channelCount() {
80
+ return this.channels.size;
81
+ }
82
+ }
83
+ exports.SocketRegistry = SocketRegistry;
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.355",
4
+ "version": "5.0.357",
5
5
  "author": "Alejandro Sanchez @alesanchezr",
6
6
  "contributors": [
7
7
  {
@@ -26,6 +26,7 @@ import * as fs from "fs"
26
26
  import {
27
27
  // createCodeFile,
28
28
  translateExercise,
29
+ translateExerciseAndWait,
29
30
  isValidRigoToken,
30
31
  readmeCreator,
31
32
  // makeReadmeReadable,
@@ -51,7 +52,6 @@ import {
51
52
  import axios from "axios"
52
53
  import * as FormData from "form-data"
53
54
  import api, {
54
- BREATHECODE_TELEMETRY_URL,
55
55
  RIGOBOT_HOST,
56
56
  RIGOBOT_REALTIME_HOST,
57
57
  listUserAcademies,
@@ -74,6 +74,13 @@ import {
74
74
  SyncNotification,
75
75
  } from "../models/creator"
76
76
  import { exportToScorm, exportToEpub, exportToZip } from "../utils/export"
77
+ import { createIngestRouter } from "../services/ingest/http/router"
78
+ import {
79
+ uploadFileToBucket,
80
+ uploadBinaryToBucket,
81
+ } from "../utils/coursePackage/bucketIo"
82
+ import { createLearnJson } from "../utils/coursePackage/learnJson"
83
+ import { createInitialSidebar } from "../utils/coursePackage/sidebar"
77
84
  import { generateAndPersistPackageManifest } from "../utils/packageManifest"
78
85
  import { requireGcsBucketName } from "../utils/gcsBucketName"
79
86
  import {
@@ -161,56 +168,6 @@ async function serializeByCourse<T>(
161
168
  return next
162
169
  }
163
170
 
164
- export const createLearnJson = (courseInfo: FormState) => {
165
- // console.log("courseInfo to create learn json", courseInfo)
166
-
167
- const expectedPreviewUrl = `https://${courseInfo.slug}.learn-pack.com/preview.png`
168
-
169
- const language = courseInfo.language || "en"
170
-
171
- const learnJson = {
172
- slug: courseInfo.slug,
173
- title: {
174
- [language]: courseInfo.title,
175
- },
176
- technologies: courseInfo.technologies || [],
177
- difficulty: "beginner",
178
- description: {
179
- [language]: courseInfo.description,
180
- },
181
- grading: "isolated",
182
- telemetry: {
183
- batch: BREATHECODE_TELEMETRY_URL,
184
- },
185
- preview: expectedPreviewUrl,
186
- }
187
- return learnJson
188
- }
189
-
190
- const uploadFileToBucket = async (
191
- bucket: Bucket,
192
- file: string,
193
- path: string
194
- ) => {
195
- const isReadme = /readme(\.\w+)?\.md$/i.test(path)
196
- const content = isReadme ? sanitizeReadmeNewlines(file) : file
197
- const fileRef = bucket.file(path)
198
- await fileRef.save(Buffer.from(content, "utf8"))
199
- }
200
-
201
- const uploadBinaryToBucket = async (
202
- bucket: Bucket,
203
- buffer: Buffer,
204
- path: string,
205
- contentType?: string
206
- ) => {
207
- const fileRef = bucket.file(path)
208
- await fileRef.save(buffer, {
209
- resumable: false,
210
- ...(contentType && { contentType }),
211
- })
212
- }
213
-
214
171
  const getGithubCredentials = () => {
215
172
  const token = process.env.GITHUB_TOKEN?.trim()
216
173
  const username = process.env.GITHUB_USERNAME?.trim()
@@ -518,20 +475,6 @@ export const processImage = async (
518
475
  }
519
476
  }
520
477
 
521
- const createInitialSidebar = async (
522
- slugs: string[],
523
- initialLanguage = "en"
524
- ) => {
525
- const sidebar: Record<string, Record<string, string>> = {}
526
- for (const slug of slugs) {
527
- sidebar[slug] = {
528
- [initialLanguage]: slug,
529
- }
530
- }
531
-
532
- return sidebar
533
- }
534
-
535
478
  const uploadInitialReadme = async (
536
479
  bucket: Bucket,
537
480
  exSlug: string,
@@ -1257,6 +1200,34 @@ const getTitleFromHTML = (html: string) => {
1257
1200
  return titleMatch ? titleMatch[1] : null
1258
1201
  }
1259
1202
 
1203
+ /** How often the notification is touched while a translation is running. */
1204
+ const SYNC_HEARTBEAT_INTERVAL_MS = 60 * 1000
1205
+
1206
+ /**
1207
+ * A synchronization with no news for this long is considered stuck: the GET
1208
+ * endpoint flags it as failed and it can be accepted again.
1209
+ */
1210
+ const SYNC_PROCESSING_TIMEOUT_MS = 3 * 60 * 1000
1211
+
1212
+ // True when a processing notification stopped reporting progress.
1213
+ function isSyncProcessingStale(notification: SyncNotification): boolean {
1214
+ const lastUpdate =
1215
+ notification.processingLastUpdate || notification.updatedAt
1216
+ return Date.now() - lastUpdate > SYNC_PROCESSING_TIMEOUT_MS
1217
+ }
1218
+
1219
+ function findSyncNotification(
1220
+ syllabus: Syllabus,
1221
+ exerciseSlug: string,
1222
+ notificationId: string
1223
+ ) {
1224
+ const lesson = syllabus.lessons.find(
1225
+ lesson => slugify(lesson.id + "-" + lesson.title) === exerciseSlug
1226
+ )
1227
+
1228
+ return lesson?.syncNotifications?.find(n => n.id === notificationId)
1229
+ }
1230
+
1260
1231
  async function processSyncTranslationsSequentially(
1261
1232
  courseSlug: string,
1262
1233
  exerciseSlug: string,
@@ -1269,30 +1240,72 @@ async function processSyncTranslationsSequentially(
1269
1240
  ) {
1270
1241
  try {
1271
1242
  const sanitizedSource = sanitizeReadmeNewlines(sourceReadmeContent)
1243
+
1244
+ let lastHeartbeatAt = Date.now()
1245
+
1246
+ // Keeps `processingLastUpdate` fresh while a language is being translated,
1247
+ // otherwise the PROCESSING_TIMEOUT in GET /sync-notifications can flag a
1248
+ // running synchronization as failed. Throttled, since every beat rewrites
1249
+ // the syllabus in the bucket.
1250
+ const heartbeat = async (targetLang: string) => {
1251
+ if (Date.now() - lastHeartbeatAt < SYNC_HEARTBEAT_INTERVAL_MS) {
1252
+ return
1253
+ }
1254
+
1255
+ lastHeartbeatAt = Date.now()
1256
+
1257
+ try {
1258
+ await serializeByCourse(courseSlug, async () => {
1259
+ const syllabus = await getSyllabus(courseSlug, bucket)
1260
+ const notification = findSyncNotification(
1261
+ syllabus,
1262
+ exerciseSlug,
1263
+ notificationId
1264
+ )
1265
+
1266
+ if (!notification) {
1267
+ return
1268
+ }
1269
+
1270
+ notification.processingLastUpdate = Date.now()
1271
+
1272
+ if (notification.syncProgress) {
1273
+ notification.syncProgress.currentLanguage = targetLang
1274
+ }
1275
+
1276
+ await saveSyllabus(courseSlug, syllabus, bucket)
1277
+ })
1278
+ } catch (error) {
1279
+ console.warn(
1280
+ `🔄 SYNC: heartbeat failed for ${exerciseSlug}:`,
1281
+ (error as Error).message
1282
+ )
1283
+ }
1284
+ }
1285
+
1272
1286
  // Process translations sequentially (no race conditions)
1273
1287
  for (const targetLang of targetLanguages) {
1274
1288
  try {
1275
- // Call Rigobot directly with synchronous execution (no webhook needed)
1289
+ const languageStartedAt = Date.now()
1290
+
1291
+ // The translation runs as an asynchronous Rigobot job that we poll:
1292
+ // waiting for it inside a single request takes 28-45s for a full
1293
+ // README and Heroku kills anything above 30s.
1276
1294
  // eslint-disable-next-line no-await-in-loop
1277
- const response = await axios.post(
1278
- `${RIGOBOT_HOST}/v1/prompting/completion/translate-asset-markdown/`,
1295
+ const translationResult = await translateExerciseAndWait(
1296
+ rigoToken,
1279
1297
  {
1280
- inputs: {
1281
- text_to_translate: sanitizedSource,
1282
- output_language: targetLang,
1283
- },
1284
- include_purpose_objective: false,
1285
- execute_async: false, // Synchronous execution
1298
+ text_to_translate: sanitizedSource,
1299
+ output_language: targetLang,
1286
1300
  },
1287
- {
1288
- headers: {
1289
- "Content-Type": "application/json",
1290
- Authorization: "Token " + rigoToken,
1291
- },
1292
- }
1301
+ { onPoll: () => heartbeat(targetLang) }
1293
1302
  )
1294
1303
 
1295
- const translationResult = response.data
1304
+ console.log(
1305
+ `🔄 SYNC: ${targetLang} translated in ${Math.round(
1306
+ (Date.now() - languageStartedAt) / 1000
1307
+ )}s (job ${translationResult.id})`
1308
+ )
1296
1309
 
1297
1310
  // Check if translation was successful
1298
1311
  if (!translationResult.parsed?.translation) {
@@ -1428,10 +1441,22 @@ async function processSyncTranslationsSequentially(
1428
1441
  (notification.syncProgress.failedLanguages?.length || 0)
1429
1442
 
1430
1443
  if (totalProcessed === notification.syncProgress.totalLanguages) {
1431
- notification.status =
1432
- notification.syncProgress.completedLanguages.length === 0 ?
1433
- "error" :
1434
- "completed"
1444
+ const completedCount =
1445
+ notification.syncProgress.completedLanguages.length
1446
+ const failedCount =
1447
+ notification.syncProgress.failedLanguages?.length || 0
1448
+
1449
+ // A partial result stays actionable: the notification remains
1450
+ // visible so the user can retry just the languages that failed.
1451
+ if (completedCount === 0) {
1452
+ notification.status = "error"
1453
+ } else if (failedCount > 0) {
1454
+ notification.status = "partial"
1455
+ } else {
1456
+ notification.status = "completed"
1457
+ }
1458
+
1459
+ notification.updatedAt = Date.now()
1435
1460
 
1436
1461
  await saveSyllabus(courseSlug, syllabus, bucket)
1437
1462
 
@@ -1598,6 +1623,7 @@ class ServeCommand extends SessionCommand {
1598
1623
  // app.use(express.static(distPath))
1599
1624
  app.use(express.json({ limit: "20mb" }))
1600
1625
  app.use(cors())
1626
+ app.use("/generation", createIngestRouter())
1601
1627
 
1602
1628
  const appPath = path.resolve(__dirname, "../ui/_app")
1603
1629
  const tarPath = path.resolve(__dirname, "../ui/app.tar.gz")
@@ -4343,45 +4369,42 @@ class ServeCommand extends SessionCommand {
4343
4369
  return res.status(404).json({ error: "Syllabus not found" })
4344
4370
  }
4345
4371
 
4346
- const PROCESSING_TIMEOUT = 3 * 60 * 1000 // 3 minutes
4347
4372
  let modified = false
4348
4373
 
4349
- // Collect active notifications (pending, processing, or error)
4374
+ // Collect active notifications (pending, processing, partial or error)
4350
4375
  const notifications: any[] = []
4351
4376
 
4352
4377
  for (const lesson of syllabus.lessons) {
4353
4378
  if (lesson.syncNotifications && lesson.syncNotifications.length > 0) {
4354
4379
  for (const notification of lesson.syncNotifications) {
4355
4380
  // Check for timeout in processing notifications
4356
- if (notification.status === "processing") {
4357
- // Use processingLastUpdate if available, otherwise fallback to updatedAt
4358
- const processingLastUpdateTime =
4359
- notification.processingLastUpdate || notification.updatedAt
4360
- const timeSinceProcessingStarted =
4361
- Date.now() - processingLastUpdateTime
4362
-
4363
- if (timeSinceProcessingStarted > PROCESSING_TIMEOUT) {
4364
- notification.status = "error"
4365
- notification.error = {
4366
- message: "Synchronization timeout - process took too long",
4367
- code: "PROCESSING_TIMEOUT",
4368
- timestamp: Date.now(),
4369
- }
4370
- modified = true
4371
-
4372
- // Emit error event
4373
- emitToCourse(courseSlug, "sync-notification-error", {
4374
- exerciseSlug: slugify(lesson.id + "-" + lesson.title),
4375
- notificationId: notification.id,
4376
- error: "Processing timeout",
4377
- })
4381
+ if (
4382
+ notification.status === "processing" &&
4383
+ isSyncProcessingStale(notification)
4384
+ ) {
4385
+ notification.status = "error"
4386
+ notification.error = {
4387
+ message: "Synchronization timeout - process took too long",
4388
+ code: "PROCESSING_TIMEOUT",
4389
+ timestamp: Date.now(),
4378
4390
  }
4391
+ modified = true
4392
+
4393
+ // Emit error event
4394
+ emitToCourse(courseSlug, "sync-notification-error", {
4395
+ exerciseSlug: slugify(lesson.id + "-" + lesson.title),
4396
+ notificationId: notification.id,
4397
+ error: "Processing timeout",
4398
+ })
4379
4399
  }
4380
4400
 
4381
- // Include active notifications (pending, processing, or error)
4401
+ // Include active notifications (pending, processing, partial or
4402
+ // error): a partial sync is still actionable, the user can retry
4403
+ // the languages that failed.
4382
4404
  if (
4383
4405
  notification.status === "pending" ||
4384
4406
  notification.status === "processing" ||
4407
+ notification.status === "partial" ||
4385
4408
  notification.status === "error"
4386
4409
  ) {
4387
4410
  notifications.push({
@@ -4521,9 +4544,24 @@ class ServeCommand extends SessionCommand {
4521
4544
  return res.status(404).json({ error: "Notification not found" })
4522
4545
  }
4523
4546
 
4524
- if (notification.status !== "pending") {
4547
+ // A synchronization can be accepted again unless it is actively
4548
+ // running: after a failure or a partial result this same
4549
+ // notification is what the Retry button acts on.
4550
+ if (
4551
+ notification.status === "processing" &&
4552
+ !isSyncProcessingStale(notification)
4553
+ ) {
4554
+ return res.status(409).json({
4555
+ error: "Notification is already being synchronized",
4556
+ code: "ALREADY_PROCESSING",
4557
+ currentStatus: notification.status,
4558
+ })
4559
+ }
4560
+
4561
+ if (notification.status === "completed") {
4525
4562
  return res.status(400).json({
4526
- error: "Notification is not pending",
4563
+ error: "Notification is already synchronized",
4564
+ code: "ALREADY_COMPLETED",
4527
4565
  currentStatus: notification.status,
4528
4566
  })
4529
4567
  }
@@ -4545,10 +4583,16 @@ class ServeCommand extends SessionCommand {
4545
4583
  })
4546
4584
  }
4547
4585
 
4548
- // Determine target languages
4586
+ // Determine target languages. On a retry only what is still missing
4587
+ // is translated again, so the languages that already succeeded are
4588
+ // neither paid for twice nor overwritten.
4589
+ const alreadyCompleted =
4590
+ notification.syncProgress?.completedLanguages || []
4549
4591
  const availableLanguages = Object.keys(lesson.translations || {})
4550
4592
  const targetLanguages = availableLanguages.filter(
4551
- lang => lang !== notification.sourceLanguage
4593
+ lang =>
4594
+ lang !== notification.sourceLanguage &&
4595
+ !alreadyCompleted.includes(lang)
4552
4596
  )
4553
4597
 
4554
4598
  if (targetLanguages.length === 0) {
@@ -4563,8 +4607,12 @@ class ServeCommand extends SessionCommand {
4563
4607
  lesson.syncNotifications?.filter(n => n.id === notificationId) ||
4564
4608
  []
4565
4609
 
4610
+ // Start from a clean slate so a previous failure does not leak into
4611
+ // this run.
4566
4612
  notification.status = "processing"
4613
+ notification.updatedAt = Date.now()
4567
4614
  notification.processingLastUpdate = Date.now()
4615
+ delete notification.error
4568
4616
  notification.syncProgress = {
4569
4617
  totalLanguages: targetLanguages.length,
4570
4618
  completedLanguages: [], // Array of completed language codes
@@ -1,54 +1,66 @@
1
- # React + TypeScript + Vite
2
-
3
- This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
-
5
- Currently, two official plugins are available:
6
-
7
- - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8
- - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
-
10
- ## Expanding the ESLint configuration
11
-
12
- If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
13
-
14
- ```js
15
- export default tseslint.config({
16
- extends: [
17
- // Remove ...tseslint.configs.recommended and replace with this
18
- ...tseslint.configs.recommendedTypeChecked,
19
- // Alternatively, use this for stricter rules
20
- ...tseslint.configs.strictTypeChecked,
21
- // Optionally, add this for stylistic rules
22
- ...tseslint.configs.stylisticTypeChecked,
23
- ],
24
- languageOptions: {
25
- // other options...
26
- parserOptions: {
27
- project: ["./tsconfig.node.json", "./tsconfig.app.json"],
28
- tsconfigRootDir: import.meta.dirname,
29
- },
30
- },
31
- })
1
+ # LearnPack Creator
2
+
3
+ The React + Vite app behind the `/creator` routes: the wizard that collects a course
4
+ brief, and the syllabus editor where the user refines the generated outline before the
5
+ course is created.
6
+
7
+ It is a separate TypeScript project from the CLI. The root `tsconfig.json` explicitly
8
+ excludes `src/creator`, and this app is ESM (`"type": "module"`) while the CLI is
9
+ CommonJS. Nothing here is compiled by the CLI build.
10
+
11
+ ## ⚠️ Read this before you commit
12
+
13
+ A PR that changes `src/creator/src/**` **must not** include `src/creatorDist/**`.
14
+
15
+ `src/creatorDist/` is the compiled output of this app, committed to git because it is
16
+ what the CLI serves at runtime and ships to npm. It is a **release artifact**: it gets
17
+ rebuilt and committed only in the release commit, by `autoUpload.sh` at the repository
18
+ root.
19
+
20
+ Committing it in a feature PR causes conflicts that cannot be resolved by merging —
21
+ Vite emits content-hashed filenames, so every rebuild rewrites `index.html` and adds a
22
+ differently named asset.
23
+
24
+ Corollary: **merging a creator change does not deploy it.** It ships when someone
25
+ publishes.
26
+
27
+ Full rationale in [`docs/creator-bundle.md`](../../docs/creator-bundle.md).
28
+
29
+ ## Commands
30
+
31
+ Run these from `src/creator/`:
32
+
33
+ ```bash
34
+ npm install # this app has its own node_modules; the root install does not cover it
35
+ npm run dev # Vite dev server with HMR
36
+ npm run build # tsc -b && vite build -> ../creatorDist (not committed, see above)
37
+ npm run watch # vite build --watch, for testing against a local CLI server
38
+ npm run test # vitest; this app has its own runner, the root `npm test` does not cover it
39
+ npm run lint # ESLint
32
40
  ```
33
41
 
34
- You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
35
-
36
- ```js
37
- // eslint.config.js
38
- import reactX from "eslint-plugin-react-x"
39
- import reactDom from "eslint-plugin-react-dom"
40
-
41
- export default tseslint.config({
42
- plugins: {
43
- // Add the react-x and react-dom plugins
44
- "react-x": reactX,
45
- "react-dom": reactDom,
46
- },
47
- rules: {
48
- // other rules...
49
- // Enable its recommended typescript rules
50
- ...reactX.configs["recommended-typescript"].rules,
51
- ...reactDom.configs.recommended.rules,
52
- },
53
- })
42
+ ## Layout
43
+
54
44
  ```
45
+ src/
46
+ App.tsx wizard: brief -> purpose -> duration -> human check
47
+ components/
48
+ NotificationListener.tsx socket.io subscription to a Rigobot completion webhook
49
+ syllabus/ syllabus editor and its chat sidebar
50
+ utils/
51
+ rigo.ts calls to the Rigobot prompting API
52
+ socket.ts socket.io client wrapper
53
+ store.ts zustand store, persisted to localStorage
54
+ locales/ i18n resources (en, es)
55
+ ```
56
+
57
+ ## How generation works
58
+
59
+ Course generation is asynchronous. `publicInteractiveCreation` posts to Rigobot with a
60
+ `webhook_url` pointing at the CLI server (`POST /notifications/:id`), and Rigobot answers
61
+ `201` immediately with a pending job. The CLI relays the eventual webhook over socket.io,
62
+ and `NotificationListener` resolves it in the browser.
63
+
64
+ That means **an HTTP 2xx from Rigobot says nothing about whether generation succeeded**.
65
+ The job can still fail later and arrive over the socket with `status: "ERROR"` and
66
+ `parsed: null`. Any code consuming a notification payload has to handle that.