@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.
- package/lib/commands/publish.js +6 -0
- package/lib/commands/serve.js +85 -259
- package/lib/models/creator.d.ts +6 -0
- package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
- package/lib/scripts/descriptionsGcsBackfill.js +141 -0
- package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
- package/lib/scripts/descriptionsS3Backfill.js +157 -0
- package/lib/scripts/descriptionsSweep.d.ts +1 -0
- package/lib/scripts/descriptionsSweep.js +142 -0
- package/lib/utils/api.d.ts +7 -0
- package/lib/utils/api.js +8 -1
- package/lib/utils/creatorUtilities.js +2 -1
- package/lib/utils/descriptionHash.d.ts +66 -0
- package/lib/utils/descriptionHash.js +173 -0
- package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
- package/lib/utils/descriptions/gcsStorage.js +60 -0
- package/lib/utils/descriptions/generateCourseDescriptions.d.ts +59 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +173 -0
- package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
- package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
- package/lib/utils/descriptions/publishStage.d.ts +69 -0
- package/lib/utils/descriptions/publishStage.js +234 -0
- package/lib/utils/descriptions/resumePublication.d.ts +36 -0
- package/lib/utils/descriptions/resumePublication.js +113 -0
- package/lib/utils/descriptions/s3Storage.d.ts +30 -0
- package/lib/utils/descriptions/s3Storage.js +134 -0
- package/lib/utils/descriptions/workList.d.ts +75 -0
- package/lib/utils/descriptions/workList.js +177 -0
- package/lib/utils/gcsBucketName.d.ts +10 -0
- package/lib/utils/gcsBucketName.js +19 -0
- package/lib/utils/packageManifest.d.ts +14 -0
- package/lib/utils/packageManifest.js +49 -5
- package/lib/utils/publishEvents.d.ts +66 -0
- package/lib/utils/publishEvents.js +111 -0
- package/lib/utils/publishJournal.d.ts +119 -0
- package/lib/utils/publishJournal.js +275 -0
- package/lib/utils/rigoActions.d.ts +44 -0
- package/lib/utils/rigoActions.js +75 -1
- package/lib/utils/s3/packageManifestBackfill.d.ts +2 -0
- package/lib/utils/s3/packageManifestBackfill.js +2 -0
- package/lib/utils/syllabusSync.d.ts +71 -0
- package/lib/utils/syllabusSync.js +273 -0
- package/package.json +1 -1
- package/src/commands/publish.ts +7 -0
- package/src/commands/serve.ts +144 -335
- package/src/models/creator.ts +9 -0
- package/src/scripts/descriptionsGcsBackfill.ts +193 -0
- package/src/scripts/descriptionsS3Backfill.ts +208 -0
- package/src/scripts/descriptionsSweep.ts +185 -0
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +142 -140
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +9 -0
- package/src/utils/creatorUtilities.ts +2 -1
- package/src/utils/descriptionHash.ts +196 -0
- package/src/utils/descriptions/gcsStorage.ts +67 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +301 -0
- package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
- package/src/utils/descriptions/publishStage.ts +382 -0
- package/src/utils/descriptions/resumePublication.ts +200 -0
- package/src/utils/descriptions/s3Storage.ts +206 -0
- package/src/utils/descriptions/workList.ts +283 -0
- package/src/utils/gcsBucketName.ts +19 -0
- package/src/utils/packageManifest.ts +54 -4
- package/src/utils/publishEvents.ts +181 -0
- package/src/utils/publishJournal.ts +383 -0
- package/src/utils/rigoActions.ts +130 -0
- package/src/utils/s3/packageManifestBackfill.ts +2 -2
- 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
|
-
|
|
176
|
-
|
|
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
|
+
}
|