@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
@@ -13,6 +13,14 @@ const frontMatter = require("front-matter")
13
13
  export const PACKAGE_MANIFEST_FILENAME = "package-manifest.json"
14
14
  export const PACKAGE_MANIFEST_REL_PATH = ".learn/package-manifest.json"
15
15
  export const SCHEMA_VERSION = 1
16
+ // Version of the Rigobot prompt/model used to generate step descriptions. It
17
+ // travels inside the completion inputs, so bumping it both busts Rigobot's
18
+ // cache and marks every description stored at a lower version for regeneration.
19
+ // Keep in sync with the prompt body provisioned in Rigobot.
20
+ export const DESCRIPTION_PROMPT_VERSION = 1
21
+
22
+ // Target length, in words, of a generated step description.
23
+ export const DESCRIPTION_TARGET_WORD_COUNT = 25
16
24
 
17
25
  export type LessonType = "READ" | "CODE" | "QUIZ";
18
26
 
@@ -167,13 +175,18 @@ function resolveLessonTitle(
167
175
  }
168
176
 
169
177
  function resolveLessonDescriptions(
170
- exercise: Exercise
178
+ exercise: Exercise,
179
+ syllabusLesson: Lesson | undefined
171
180
  ): Record<string, string | null> {
172
181
  const description: Record<string, string | null> = {}
173
182
 
183
+ // Descriptions are a projection: the source of truth lives in the syllabus
184
+ // lesson (translations[lang].description), populated by the sweep/backfill.
185
+ // When absent we emit null (the frontend hides the area) rather than a
186
+ // low-quality placeholder.
174
187
  for (const lang of Object.keys(exercise.translations)) {
175
- // TODO: add description content
176
- description[lang] = null
188
+ description[lang] =
189
+ syllabusLesson?.translations?.[lang]?.description ?? null
177
190
  }
178
191
 
179
192
  return description
@@ -240,7 +253,7 @@ export function buildPackageManifestFromSources(
240
253
  courseLang,
241
254
  readmeFrontmatters[exercise.slug] ?? {}
242
255
  ),
243
- description: resolveLessonDescriptions(exercise),
256
+ description: resolveLessonDescriptions(exercise, syllabusLesson),
244
257
  video: resolveLessonVideos(exercise, readmeFrontmatters),
245
258
  }
246
259
  })
@@ -312,6 +325,43 @@ export function serializePackageManifest(manifest: PackageManifest): string {
312
325
  return JSON.stringify(manifest, null, 2)
313
326
  }
314
327
 
328
+ /** The manifest as it travels inside a publication event. */
329
+ export type PackageManifestEventView = Omit<PackageManifest, "generatedAt">;
330
+
331
+ /**
332
+ * Project a manifest into the shape sent to breathecode.
333
+ *
334
+ * Today this is nearly the identity — only `generatedAt`, an internal detail of
335
+ * the projection, is dropped. It exists as an explicit pick so that fields
336
+ * added to the manifest later (hashes, generation flags) do not silently become
337
+ * part of an external contract: joining the event payload has to be a decision
338
+ * visible in a diff.
339
+ */
340
+ export function serializePackageManifestForEvent(
341
+ manifest: PackageManifest
342
+ ): PackageManifestEventView {
343
+ return {
344
+ schemaVersion: manifest.schemaVersion,
345
+ publishedAt: manifest.publishedAt,
346
+ slug: manifest.slug,
347
+ title: manifest.title,
348
+ description: manifest.description,
349
+ preview: manifest.preview,
350
+ technologies: manifest.technologies,
351
+ difficulty: manifest.difficulty,
352
+ duration: manifest.duration,
353
+ lessons: manifest.lessons.map(lesson => ({
354
+ id: lesson.id,
355
+ slug: lesson.slug,
356
+ position: lesson.position,
357
+ type: lesson.type,
358
+ title: lesson.title,
359
+ description: lesson.description,
360
+ video: lesson.video,
361
+ })),
362
+ }
363
+ }
364
+
315
365
  async function readReadmeFrontmatter(
316
366
  bucket: Bucket,
317
367
  exerciseSlug: string,
@@ -0,0 +1,181 @@
1
+ import axios from "axios"
2
+ import { BREATHECODE_EVENTS_URL, RIGOBOT_HOST } from "./api"
3
+ import {
4
+ PackageManifest,
5
+ PackageManifestEventView,
6
+ serializePackageManifestForEvent,
7
+ } from "./packageManifest"
8
+
9
+ /**
10
+ * Publication events sent to breathecode.
11
+ *
12
+ * They are emitted from here, and not from Rigobot, because learnpack-cli is
13
+ * the only party that witnesses the facts: Rigobot's part ends when the zip is
14
+ * deployed, before the breathecode asset sync, and it never sees the end of the
15
+ * asynchronous description stage.
16
+ *
17
+ * Transport is the existing telemetry endpoint. The discriminator is a
18
+ * top-level `event` key, which is what breathecode's webhook log already looks
19
+ * for; a body without it keeps being processed as plain telemetry.
20
+ *
21
+ * `package_slug` and `package_id` are duplicated at the top level on purpose:
22
+ * `LearnPack.add_webhook_to_log` fills the indexed columns from there, so
23
+ * without them the events would land as rows nobody can filter in the webhook
24
+ * viewer.
25
+ */
26
+
27
+ export const PACKAGE_PUBLISHED_EVENT = "package_published"
28
+ export const PACKAGE_MANIFEST_UPDATED_EVENT = "package_manifest_updated"
29
+
30
+ /** Whether a second event is coming for this publication. */
31
+ export type DescriptionsOutcome = "queued" | "up_to_date";
32
+
33
+ export type PublishEventEnvelope = {
34
+ event: string;
35
+ package_slug: string;
36
+ package_id?: number;
37
+ payload: Record<string, unknown>;
38
+ };
39
+
40
+ export type PackagePublishedPayload = {
41
+ publish_id: string;
42
+ package: Record<string, unknown> | null;
43
+ manifest: PackageManifestEventView | null;
44
+ descriptions: DescriptionsOutcome;
45
+ };
46
+
47
+ export type PackageManifestUpdatedPayload = {
48
+ publish_id: string;
49
+ status: "success" | "failed";
50
+ package: Record<string, unknown> | null;
51
+ manifest: PackageManifestEventView | null;
52
+ stats?: {
53
+ generated: number;
54
+ failed: number;
55
+ missing: number;
56
+ };
57
+ };
58
+
59
+ export type PublishEventContext = {
60
+ publishId: string;
61
+ courseSlug: string;
62
+ /** `GET /v1/learnpack/package/{slug}/` from Rigobot. */
63
+ packageInfo: Record<string, unknown> | null;
64
+ manifest: PackageManifest | null;
65
+ };
66
+
67
+ function packageIdOf(
68
+ packageInfo: Record<string, unknown> | null
69
+ ): number | undefined {
70
+ const id = packageInfo?.id
71
+ return typeof id === "number" ? id : undefined
72
+ }
73
+
74
+ export function buildPackagePublishedEvent(
75
+ context: PublishEventContext,
76
+ descriptions: DescriptionsOutcome
77
+ ): PublishEventEnvelope {
78
+ const payload: PackagePublishedPayload = {
79
+ publish_id: context.publishId,
80
+ package: context.packageInfo,
81
+ manifest: context.manifest ?
82
+ serializePackageManifestForEvent(context.manifest) :
83
+ null,
84
+ descriptions,
85
+ }
86
+
87
+ return {
88
+ event: PACKAGE_PUBLISHED_EVENT,
89
+ package_slug: context.courseSlug,
90
+ package_id: packageIdOf(context.packageInfo),
91
+ payload: payload as unknown as Record<string, unknown>,
92
+ }
93
+ }
94
+
95
+ export function buildPackageManifestUpdatedEvent(
96
+ context: PublishEventContext,
97
+ status: "success" | "failed",
98
+ stats?: PackageManifestUpdatedPayload["stats"]
99
+ ): PublishEventEnvelope {
100
+ const payload: PackageManifestUpdatedPayload = {
101
+ publish_id: context.publishId,
102
+ status,
103
+ package: context.packageInfo,
104
+ manifest: context.manifest ?
105
+ serializePackageManifestForEvent(context.manifest) :
106
+ null,
107
+ }
108
+
109
+ if (stats) {
110
+ payload.stats = stats
111
+ }
112
+
113
+ return {
114
+ event: PACKAGE_MANIFEST_UPDATED_EVENT,
115
+ package_slug: context.courseSlug,
116
+ package_id: packageIdOf(context.packageInfo),
117
+ payload: payload as unknown as Record<string, unknown>,
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Fire and forget: a publication must never fail because breathecode is down,
123
+ * so every error is swallowed and logged. Returns whether it was delivered, for
124
+ * the journal, not for control flow.
125
+ */
126
+ export async function sendPublishEvent(
127
+ envelope: PublishEventEnvelope,
128
+ breathecodeToken: string,
129
+ timeoutMs = 10_000
130
+ ): Promise<boolean> {
131
+ if (!breathecodeToken) {
132
+ console.warn(
133
+ `[publish-events] Skipping "${envelope.event}": no breathecode token`
134
+ )
135
+ return false
136
+ }
137
+
138
+ try {
139
+ await axios.post(BREATHECODE_EVENTS_URL, envelope, {
140
+ timeout: timeoutMs,
141
+ headers: {
142
+ "Content-Type": "application/json",
143
+ Authorization: "Token " + breathecodeToken.trim(),
144
+ },
145
+ })
146
+ return true
147
+ } catch (error) {
148
+ console.error(
149
+ `[publish-events] Could not deliver "${envelope.event}" for "${envelope.package_slug}":`,
150
+ (error as Error).message
151
+ )
152
+ return false
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Package information for the event payload, straight from Rigobot's own
158
+ * serializer so the shape stays owned by the system that owns the model.
159
+ * Returns null on failure: an event with less context still beats no event.
160
+ */
161
+ export async function fetchPackageInfo(
162
+ courseSlug: string,
163
+ rigobotToken: string
164
+ ): Promise<Record<string, unknown> | null> {
165
+ try {
166
+ const response = await axios.get(
167
+ `${RIGOBOT_HOST}/v1/learnpack/package/${courseSlug}/`,
168
+ {
169
+ timeout: 10_000,
170
+ headers: { Authorization: "Token " + rigobotToken.trim() },
171
+ }
172
+ )
173
+ return response.data as Record<string, unknown>
174
+ } catch (error) {
175
+ console.error(
176
+ `[publish-events] Could not read package "${courseSlug}" from Rigobot:`,
177
+ (error as Error).message
178
+ )
179
+ return null
180
+ }
181
+ }
@@ -0,0 +1,383 @@
1
+ import { Bucket } from "@google-cloud/storage"
2
+ import { v4 as uuidv4 } from "uuid"
3
+
4
+ /**
5
+ * Publish journal — per-stage state of a publication.
6
+ *
7
+ * Publishing is a distributed transaction across three systems (S3 via Rigobot,
8
+ * breathecode assets, and our own buckets) plus an asynchronous description
9
+ * stage. Some of its outcomes are NOT derivable from content afterwards: in
10
+ * particular "was the manifest-updated event already emitted?" leaves no trace
11
+ * in S3 or GCS. The journal records what happened per stage so the sweep can
12
+ * resume an interrupted publication and honour the event contract (a publish
13
+ * that announced `descriptions: "queued"` must always be followed by exactly
14
+ * one `package_manifest_updated`).
15
+ *
16
+ * Design notes:
17
+ *
18
+ * - **Best effort, never a lock.** Every write swallows its errors: if the
19
+ * journal cannot be written the publication still proceeds. Losing the
20
+ * journal degrades us to the previous behaviour (no recovery, no diagnosis),
21
+ * which is acceptable; blocking a publication because a bookkeeping write
22
+ * failed is not.
23
+ * - **Flat key space.** Journals live in `publish-journal/{slug}__{id}.json`,
24
+ * a sibling prefix of `courses/`, so the sweep can list every unfinished
25
+ * publication with one prefix query and the concurrency guard can list a
26
+ * single course with another, without maintaining an index.
27
+ * - **Explicit cleanup.** A journal is deleted once every stage succeeded, and
28
+ * kept when any stage failed (visibility + retry). GCS lifecycle rules would
29
+ * be the natural alternative, but the service account only holds
30
+ * object-level permissions (`storage.buckets.*` is denied), so the cleanup
31
+ * has to happen in code.
32
+ */
33
+
34
+ export const JOURNAL_PREFIX = "publish-journal/"
35
+
36
+ /**
37
+ * Stages of a publication, in execution order.
38
+ *
39
+ * Only stages the orchestrator can observe on its own: writing the asset id
40
+ * back into Rigobot happens inside the asset sync call, so it is covered by
41
+ * `assetsSync` rather than being a stage that could never be marked.
42
+ */
43
+ export const STAGE_NAMES = [
44
+ "deploy",
45
+ "assetsSync",
46
+ "publishedEvent",
47
+ "descriptions",
48
+ "gcsMirror",
49
+ "manifestEvent",
50
+ ] as const
51
+
52
+ export type StageName = typeof STAGE_NAMES[number];
53
+
54
+ export type StageStatus = "pending" | "running" | "done" | "failed";
55
+
56
+ export type Stage = {
57
+ status: StageStatus;
58
+ at?: string;
59
+ error?: string;
60
+ meta?: unknown;
61
+ };
62
+
63
+ export type PublishJournal = {
64
+ publishId: string;
65
+ courseSlug: string;
66
+ startedAt: string;
67
+ updatedAt: string;
68
+ /** Times the sweep has picked this journal up, to bound retries. */
69
+ attempts: number;
70
+ /** Set once retries are exhausted: kept for a human, ignored by the sweep. */
71
+ abandoned?: boolean;
72
+ stages: Record<StageName, Stage>;
73
+ };
74
+
75
+ export type JournalRef = {
76
+ courseSlug: string;
77
+ publishId: string;
78
+ key: string;
79
+ };
80
+
81
+ /**
82
+ * Minimal storage surface the journal needs, so callers can pass the real
83
+ * bucket adapter or an in-memory double. Object-level operations only.
84
+ */
85
+ export interface JournalStorage {
86
+ save(key: string, contents: string): Promise<void>;
87
+ read(key: string): Promise<string | null>;
88
+ remove(key: string): Promise<void>;
89
+ list(prefix: string): Promise<string[]>;
90
+ }
91
+
92
+ export function journalKey(courseSlug: string, publishId: string): string {
93
+ return `${JOURNAL_PREFIX}${courseSlug}__${publishId}.json`
94
+ }
95
+
96
+ export function coursePrefix(courseSlug: string): string {
97
+ return `${JOURNAL_PREFIX}${courseSlug}__`
98
+ }
99
+
100
+ /** Inverse of `journalKey`; returns null for keys that don't match the shape. */
101
+ export function parseJournalKey(key: string): JournalRef | null {
102
+ if (!key.startsWith(JOURNAL_PREFIX) || !key.endsWith(".json")) {
103
+ return null
104
+ }
105
+
106
+ const name = key.slice(JOURNAL_PREFIX.length, -".json".length)
107
+ const separator = name.indexOf("__")
108
+ if (separator <= 0) {
109
+ return null
110
+ }
111
+
112
+ const courseSlug = name.slice(0, separator)
113
+ const publishId = name.slice(separator + 2)
114
+ if (!courseSlug || !publishId) {
115
+ return null
116
+ }
117
+
118
+ return { courseSlug, publishId, key }
119
+ }
120
+
121
+ function emptyStages(): Record<StageName, Stage> {
122
+ const stages = {} as Record<StageName, Stage>
123
+ for (const name of STAGE_NAMES) {
124
+ stages[name] = { status: "pending" }
125
+ }
126
+
127
+ return stages
128
+ }
129
+
130
+ /** Persist the journal. Never throws: bookkeeping must not break a publish. */
131
+ async function persist(
132
+ storage: JournalStorage,
133
+ journal: PublishJournal
134
+ ): Promise<void> {
135
+ try {
136
+ await storage.save(
137
+ journalKey(journal.courseSlug, journal.publishId),
138
+ JSON.stringify(journal, null, 2)
139
+ )
140
+ } catch (error) {
141
+ console.warn(
142
+ `[publish-journal] Could not persist journal for "${journal.courseSlug}":`,
143
+ (error as Error).message
144
+ )
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Start a journal for a new publication. The generated `publishId` correlates
150
+ * the two publish events and is returned even if persisting failed.
151
+ */
152
+ export async function createJournal(
153
+ storage: JournalStorage,
154
+ courseSlug: string,
155
+ publishId: string = uuidv4()
156
+ ): Promise<PublishJournal> {
157
+ const now = new Date().toISOString()
158
+ const journal: PublishJournal = {
159
+ publishId,
160
+ courseSlug,
161
+ startedAt: now,
162
+ updatedAt: now,
163
+ attempts: 0,
164
+ stages: emptyStages(),
165
+ }
166
+
167
+ await persist(storage, journal)
168
+ return journal
169
+ }
170
+
171
+ /** Update one stage and persist. Mutates and returns the same journal object. */
172
+ export async function markStage(
173
+ storage: JournalStorage,
174
+ journal: PublishJournal,
175
+ stage: StageName,
176
+ status: StageStatus,
177
+ extra: { error?: string; meta?: unknown } = {}
178
+ ): Promise<PublishJournal> {
179
+ const next: Stage = { status, at: new Date().toISOString() }
180
+ if (extra.error !== undefined) {
181
+ next.error = extra.error
182
+ }
183
+
184
+ if (extra.meta !== undefined) {
185
+ next.meta = extra.meta
186
+ }
187
+
188
+ journal.stages[stage] = next
189
+ journal.updatedAt = next.at as string
190
+ await persist(storage, journal)
191
+ return journal
192
+ }
193
+
194
+ /** Read a journal. Returns null when missing or unreadable. */
195
+ export async function readJournal(
196
+ storage: JournalStorage,
197
+ courseSlug: string,
198
+ publishId: string
199
+ ): Promise<PublishJournal | null> {
200
+ return readJournalByKey(storage, journalKey(courseSlug, publishId))
201
+ }
202
+
203
+ export async function readJournalByKey(
204
+ storage: JournalStorage,
205
+ key: string
206
+ ): Promise<PublishJournal | null> {
207
+ try {
208
+ const raw = await storage.read(key)
209
+ if (!raw) {
210
+ return null
211
+ }
212
+
213
+ const parsed = JSON.parse(raw) as PublishJournal
214
+ if (!parsed || !parsed.publishId || !parsed.stages) {
215
+ return null
216
+ }
217
+
218
+ // Tolerate journals written before a stage was added to STAGE_NAMES.
219
+ for (const name of STAGE_NAMES) {
220
+ if (!parsed.stages[name]) {
221
+ parsed.stages[name] = { status: "pending" }
222
+ }
223
+ }
224
+
225
+ return parsed
226
+ } catch (error) {
227
+ console.warn(
228
+ `[publish-journal] Could not read journal "${key}":`,
229
+ (error as Error).message
230
+ )
231
+ return null
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Every journal still stored, i.e. every publication that did not finish
237
+ * cleanly. Returns an empty list on error (the sweep falls back to its deep
238
+ * scan rather than crashing).
239
+ */
240
+ export async function listJournalRefs(
241
+ storage: JournalStorage
242
+ ): Promise<JournalRef[]> {
243
+ return listRefsByPrefix(storage, JOURNAL_PREFIX)
244
+ }
245
+
246
+ /** Journals of a single course; backs the concurrent-publish guard. */
247
+ export async function listJournalRefsForCourse(
248
+ storage: JournalStorage,
249
+ courseSlug: string
250
+ ): Promise<JournalRef[]> {
251
+ return listRefsByPrefix(storage, coursePrefix(courseSlug))
252
+ }
253
+
254
+ async function listRefsByPrefix(
255
+ storage: JournalStorage,
256
+ prefix: string
257
+ ): Promise<JournalRef[]> {
258
+ try {
259
+ const keys = await storage.list(prefix)
260
+ const refs: JournalRef[] = []
261
+ for (const key of keys) {
262
+ const ref = parseJournalKey(key)
263
+ if (ref) {
264
+ refs.push(ref)
265
+ }
266
+ }
267
+
268
+ return refs
269
+ } catch (error) {
270
+ console.warn(
271
+ `[publish-journal] Could not list journals "${prefix}":`,
272
+ (error as Error).message
273
+ )
274
+ return []
275
+ }
276
+ }
277
+
278
+ export function isStageTerminal(stage: Stage): boolean {
279
+ return stage.status === "done" || stage.status === "failed"
280
+ }
281
+
282
+ export function hasFailedStage(journal: PublishJournal): boolean {
283
+ return STAGE_NAMES.some(name => journal.stages[name].status === "failed")
284
+ }
285
+
286
+ /** True when no stage is left to run (all done, or done/failed mix). */
287
+ export function isJournalSettled(journal: PublishJournal): boolean {
288
+ return STAGE_NAMES.every(name => isStageTerminal(journal.stages[name]))
289
+ }
290
+
291
+ /** Stages the sweep still has work to do on. */
292
+ export function pendingStages(journal: PublishJournal): StageName[] {
293
+ return STAGE_NAMES.filter(name => !isStageTerminal(journal.stages[name]))
294
+ }
295
+
296
+ /** A journal untouched for longer than `maxAgeMs` is presumed interrupted. */
297
+ export function isJournalStale(
298
+ journal: PublishJournal,
299
+ maxAgeMs: number,
300
+ now: number = Date.now()
301
+ ): boolean {
302
+ const updatedAt = Date.parse(journal.updatedAt)
303
+ if (Number.isNaN(updatedAt)) {
304
+ return true
305
+ }
306
+
307
+ return now - updatedAt > maxAgeMs
308
+ }
309
+
310
+ export async function incrementAttempts(
311
+ storage: JournalStorage,
312
+ journal: PublishJournal
313
+ ): Promise<PublishJournal> {
314
+ journal.attempts += 1
315
+ journal.updatedAt = new Date().toISOString()
316
+ await persist(storage, journal)
317
+ return journal
318
+ }
319
+
320
+ /**
321
+ * Give up on a journal after too many attempts. It is kept, not deleted: a
322
+ * publication that never completed is exactly what someone needs to see.
323
+ */
324
+ export async function abandonJournal(
325
+ storage: JournalStorage,
326
+ journal: PublishJournal
327
+ ): Promise<PublishJournal> {
328
+ journal.abandoned = true
329
+ journal.updatedAt = new Date().toISOString()
330
+ await persist(storage, journal)
331
+ return journal
332
+ }
333
+
334
+ /**
335
+ * Close a journal: delete it when every stage succeeded, keep it otherwise so
336
+ * the sweep can retry and a human can inspect it. Never throws.
337
+ */
338
+ export async function finalizeJournal(
339
+ storage: JournalStorage,
340
+ journal: PublishJournal
341
+ ): Promise<"deleted" | "kept"> {
342
+ if (!isJournalSettled(journal) || hasFailedStage(journal)) {
343
+ await persist(storage, journal)
344
+ return "kept"
345
+ }
346
+
347
+ try {
348
+ await storage.remove(journalKey(journal.courseSlug, journal.publishId))
349
+ return "deleted"
350
+ } catch (error) {
351
+ console.warn(
352
+ `[publish-journal] Could not delete journal for "${journal.courseSlug}":`,
353
+ (error as Error).message
354
+ )
355
+ return "kept"
356
+ }
357
+ }
358
+
359
+ /** GCS-backed adapter. Object-level operations only, by design. */
360
+ export function createGcsJournalStorage(bucket: Bucket): JournalStorage {
361
+ return {
362
+ async save(key, contents) {
363
+ await bucket.file(key).save(Buffer.from(contents, "utf8"), {
364
+ contentType: "application/json",
365
+ })
366
+ },
367
+ async read(key) {
368
+ try {
369
+ const [buf] = await bucket.file(key).download()
370
+ return buf.toString()
371
+ } catch {
372
+ return null
373
+ }
374
+ },
375
+ async remove(key) {
376
+ await bucket.file(key).delete()
377
+ },
378
+ async list(prefix) {
379
+ const [files] = await bucket.getFiles({ prefix })
380
+ return files.map(file => file.name)
381
+ },
382
+ }
383
+ }