@learnpack/learnpack 5.0.351 → 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 +18 -0
- package/lib/utils/packageManifest.js +81 -7
- 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 +84 -0
- package/lib/utils/s3/packageManifestBackfill.js +487 -0
- package/lib/utils/syllabusSync.d.ts +71 -0
- package/lib/utils/syllabusSync.js +273 -0
- package/package.json +4 -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 +143 -141
- 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/export/README.md +0 -2
- package/src/utils/gcsBucketName.ts +19 -0
- package/src/utils/packageManifest.ts +101 -10
- 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 +776 -0
- package/src/utils/syllabusSync.ts +390 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Bucket } from "@google-cloud/storage";
|
|
2
|
+
/**
|
|
3
|
+
* Publish journal — per-stage state of a publication.
|
|
4
|
+
*
|
|
5
|
+
* Publishing is a distributed transaction across three systems (S3 via Rigobot,
|
|
6
|
+
* breathecode assets, and our own buckets) plus an asynchronous description
|
|
7
|
+
* stage. Some of its outcomes are NOT derivable from content afterwards: in
|
|
8
|
+
* particular "was the manifest-updated event already emitted?" leaves no trace
|
|
9
|
+
* in S3 or GCS. The journal records what happened per stage so the sweep can
|
|
10
|
+
* resume an interrupted publication and honour the event contract (a publish
|
|
11
|
+
* that announced `descriptions: "queued"` must always be followed by exactly
|
|
12
|
+
* one `package_manifest_updated`).
|
|
13
|
+
*
|
|
14
|
+
* Design notes:
|
|
15
|
+
*
|
|
16
|
+
* - **Best effort, never a lock.** Every write swallows its errors: if the
|
|
17
|
+
* journal cannot be written the publication still proceeds. Losing the
|
|
18
|
+
* journal degrades us to the previous behaviour (no recovery, no diagnosis),
|
|
19
|
+
* which is acceptable; blocking a publication because a bookkeeping write
|
|
20
|
+
* failed is not.
|
|
21
|
+
* - **Flat key space.** Journals live in `publish-journal/{slug}__{id}.json`,
|
|
22
|
+
* a sibling prefix of `courses/`, so the sweep can list every unfinished
|
|
23
|
+
* publication with one prefix query and the concurrency guard can list a
|
|
24
|
+
* single course with another, without maintaining an index.
|
|
25
|
+
* - **Explicit cleanup.** A journal is deleted once every stage succeeded, and
|
|
26
|
+
* kept when any stage failed (visibility + retry). GCS lifecycle rules would
|
|
27
|
+
* be the natural alternative, but the service account only holds
|
|
28
|
+
* object-level permissions (`storage.buckets.*` is denied), so the cleanup
|
|
29
|
+
* has to happen in code.
|
|
30
|
+
*/
|
|
31
|
+
export declare const JOURNAL_PREFIX = "publish-journal/";
|
|
32
|
+
/**
|
|
33
|
+
* Stages of a publication, in execution order.
|
|
34
|
+
*
|
|
35
|
+
* Only stages the orchestrator can observe on its own: writing the asset id
|
|
36
|
+
* back into Rigobot happens inside the asset sync call, so it is covered by
|
|
37
|
+
* `assetsSync` rather than being a stage that could never be marked.
|
|
38
|
+
*/
|
|
39
|
+
export declare const STAGE_NAMES: readonly ["deploy", "assetsSync", "publishedEvent", "descriptions", "gcsMirror", "manifestEvent"];
|
|
40
|
+
export type StageName = typeof STAGE_NAMES[number];
|
|
41
|
+
export type StageStatus = "pending" | "running" | "done" | "failed";
|
|
42
|
+
export type Stage = {
|
|
43
|
+
status: StageStatus;
|
|
44
|
+
at?: string;
|
|
45
|
+
error?: string;
|
|
46
|
+
meta?: unknown;
|
|
47
|
+
};
|
|
48
|
+
export type PublishJournal = {
|
|
49
|
+
publishId: string;
|
|
50
|
+
courseSlug: string;
|
|
51
|
+
startedAt: string;
|
|
52
|
+
updatedAt: string;
|
|
53
|
+
/** Times the sweep has picked this journal up, to bound retries. */
|
|
54
|
+
attempts: number;
|
|
55
|
+
/** Set once retries are exhausted: kept for a human, ignored by the sweep. */
|
|
56
|
+
abandoned?: boolean;
|
|
57
|
+
stages: Record<StageName, Stage>;
|
|
58
|
+
};
|
|
59
|
+
export type JournalRef = {
|
|
60
|
+
courseSlug: string;
|
|
61
|
+
publishId: string;
|
|
62
|
+
key: string;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Minimal storage surface the journal needs, so callers can pass the real
|
|
66
|
+
* bucket adapter or an in-memory double. Object-level operations only.
|
|
67
|
+
*/
|
|
68
|
+
export interface JournalStorage {
|
|
69
|
+
save(key: string, contents: string): Promise<void>;
|
|
70
|
+
read(key: string): Promise<string | null>;
|
|
71
|
+
remove(key: string): Promise<void>;
|
|
72
|
+
list(prefix: string): Promise<string[]>;
|
|
73
|
+
}
|
|
74
|
+
export declare function journalKey(courseSlug: string, publishId: string): string;
|
|
75
|
+
export declare function coursePrefix(courseSlug: string): string;
|
|
76
|
+
/** Inverse of `journalKey`; returns null for keys that don't match the shape. */
|
|
77
|
+
export declare function parseJournalKey(key: string): JournalRef | null;
|
|
78
|
+
/**
|
|
79
|
+
* Start a journal for a new publication. The generated `publishId` correlates
|
|
80
|
+
* the two publish events and is returned even if persisting failed.
|
|
81
|
+
*/
|
|
82
|
+
export declare function createJournal(storage: JournalStorage, courseSlug: string, publishId?: string): Promise<PublishJournal>;
|
|
83
|
+
/** Update one stage and persist. Mutates and returns the same journal object. */
|
|
84
|
+
export declare function markStage(storage: JournalStorage, journal: PublishJournal, stage: StageName, status: StageStatus, extra?: {
|
|
85
|
+
error?: string;
|
|
86
|
+
meta?: unknown;
|
|
87
|
+
}): Promise<PublishJournal>;
|
|
88
|
+
/** Read a journal. Returns null when missing or unreadable. */
|
|
89
|
+
export declare function readJournal(storage: JournalStorage, courseSlug: string, publishId: string): Promise<PublishJournal | null>;
|
|
90
|
+
export declare function readJournalByKey(storage: JournalStorage, key: string): Promise<PublishJournal | null>;
|
|
91
|
+
/**
|
|
92
|
+
* Every journal still stored, i.e. every publication that did not finish
|
|
93
|
+
* cleanly. Returns an empty list on error (the sweep falls back to its deep
|
|
94
|
+
* scan rather than crashing).
|
|
95
|
+
*/
|
|
96
|
+
export declare function listJournalRefs(storage: JournalStorage): Promise<JournalRef[]>;
|
|
97
|
+
/** Journals of a single course; backs the concurrent-publish guard. */
|
|
98
|
+
export declare function listJournalRefsForCourse(storage: JournalStorage, courseSlug: string): Promise<JournalRef[]>;
|
|
99
|
+
export declare function isStageTerminal(stage: Stage): boolean;
|
|
100
|
+
export declare function hasFailedStage(journal: PublishJournal): boolean;
|
|
101
|
+
/** True when no stage is left to run (all done, or done/failed mix). */
|
|
102
|
+
export declare function isJournalSettled(journal: PublishJournal): boolean;
|
|
103
|
+
/** Stages the sweep still has work to do on. */
|
|
104
|
+
export declare function pendingStages(journal: PublishJournal): StageName[];
|
|
105
|
+
/** A journal untouched for longer than `maxAgeMs` is presumed interrupted. */
|
|
106
|
+
export declare function isJournalStale(journal: PublishJournal, maxAgeMs: number, now?: number): boolean;
|
|
107
|
+
export declare function incrementAttempts(storage: JournalStorage, journal: PublishJournal): Promise<PublishJournal>;
|
|
108
|
+
/**
|
|
109
|
+
* Give up on a journal after too many attempts. It is kept, not deleted: a
|
|
110
|
+
* publication that never completed is exactly what someone needs to see.
|
|
111
|
+
*/
|
|
112
|
+
export declare function abandonJournal(storage: JournalStorage, journal: PublishJournal): Promise<PublishJournal>;
|
|
113
|
+
/**
|
|
114
|
+
* Close a journal: delete it when every stage succeeded, keep it otherwise so
|
|
115
|
+
* the sweep can retry and a human can inspect it. Never throws.
|
|
116
|
+
*/
|
|
117
|
+
export declare function finalizeJournal(storage: JournalStorage, journal: PublishJournal): Promise<"deleted" | "kept">;
|
|
118
|
+
/** GCS-backed adapter. Object-level operations only, by design. */
|
|
119
|
+
export declare function createGcsJournalStorage(bucket: Bucket): JournalStorage;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.STAGE_NAMES = exports.JOURNAL_PREFIX = void 0;
|
|
4
|
+
exports.journalKey = journalKey;
|
|
5
|
+
exports.coursePrefix = coursePrefix;
|
|
6
|
+
exports.parseJournalKey = parseJournalKey;
|
|
7
|
+
exports.createJournal = createJournal;
|
|
8
|
+
exports.markStage = markStage;
|
|
9
|
+
exports.readJournal = readJournal;
|
|
10
|
+
exports.readJournalByKey = readJournalByKey;
|
|
11
|
+
exports.listJournalRefs = listJournalRefs;
|
|
12
|
+
exports.listJournalRefsForCourse = listJournalRefsForCourse;
|
|
13
|
+
exports.isStageTerminal = isStageTerminal;
|
|
14
|
+
exports.hasFailedStage = hasFailedStage;
|
|
15
|
+
exports.isJournalSettled = isJournalSettled;
|
|
16
|
+
exports.pendingStages = pendingStages;
|
|
17
|
+
exports.isJournalStale = isJournalStale;
|
|
18
|
+
exports.incrementAttempts = incrementAttempts;
|
|
19
|
+
exports.abandonJournal = abandonJournal;
|
|
20
|
+
exports.finalizeJournal = finalizeJournal;
|
|
21
|
+
exports.createGcsJournalStorage = createGcsJournalStorage;
|
|
22
|
+
const uuid_1 = require("uuid");
|
|
23
|
+
/**
|
|
24
|
+
* Publish journal — per-stage state of a publication.
|
|
25
|
+
*
|
|
26
|
+
* Publishing is a distributed transaction across three systems (S3 via Rigobot,
|
|
27
|
+
* breathecode assets, and our own buckets) plus an asynchronous description
|
|
28
|
+
* stage. Some of its outcomes are NOT derivable from content afterwards: in
|
|
29
|
+
* particular "was the manifest-updated event already emitted?" leaves no trace
|
|
30
|
+
* in S3 or GCS. The journal records what happened per stage so the sweep can
|
|
31
|
+
* resume an interrupted publication and honour the event contract (a publish
|
|
32
|
+
* that announced `descriptions: "queued"` must always be followed by exactly
|
|
33
|
+
* one `package_manifest_updated`).
|
|
34
|
+
*
|
|
35
|
+
* Design notes:
|
|
36
|
+
*
|
|
37
|
+
* - **Best effort, never a lock.** Every write swallows its errors: if the
|
|
38
|
+
* journal cannot be written the publication still proceeds. Losing the
|
|
39
|
+
* journal degrades us to the previous behaviour (no recovery, no diagnosis),
|
|
40
|
+
* which is acceptable; blocking a publication because a bookkeeping write
|
|
41
|
+
* failed is not.
|
|
42
|
+
* - **Flat key space.** Journals live in `publish-journal/{slug}__{id}.json`,
|
|
43
|
+
* a sibling prefix of `courses/`, so the sweep can list every unfinished
|
|
44
|
+
* publication with one prefix query and the concurrency guard can list a
|
|
45
|
+
* single course with another, without maintaining an index.
|
|
46
|
+
* - **Explicit cleanup.** A journal is deleted once every stage succeeded, and
|
|
47
|
+
* kept when any stage failed (visibility + retry). GCS lifecycle rules would
|
|
48
|
+
* be the natural alternative, but the service account only holds
|
|
49
|
+
* object-level permissions (`storage.buckets.*` is denied), so the cleanup
|
|
50
|
+
* has to happen in code.
|
|
51
|
+
*/
|
|
52
|
+
exports.JOURNAL_PREFIX = "publish-journal/";
|
|
53
|
+
/**
|
|
54
|
+
* Stages of a publication, in execution order.
|
|
55
|
+
*
|
|
56
|
+
* Only stages the orchestrator can observe on its own: writing the asset id
|
|
57
|
+
* back into Rigobot happens inside the asset sync call, so it is covered by
|
|
58
|
+
* `assetsSync` rather than being a stage that could never be marked.
|
|
59
|
+
*/
|
|
60
|
+
exports.STAGE_NAMES = [
|
|
61
|
+
"deploy",
|
|
62
|
+
"assetsSync",
|
|
63
|
+
"publishedEvent",
|
|
64
|
+
"descriptions",
|
|
65
|
+
"gcsMirror",
|
|
66
|
+
"manifestEvent",
|
|
67
|
+
];
|
|
68
|
+
function journalKey(courseSlug, publishId) {
|
|
69
|
+
return `${exports.JOURNAL_PREFIX}${courseSlug}__${publishId}.json`;
|
|
70
|
+
}
|
|
71
|
+
function coursePrefix(courseSlug) {
|
|
72
|
+
return `${exports.JOURNAL_PREFIX}${courseSlug}__`;
|
|
73
|
+
}
|
|
74
|
+
/** Inverse of `journalKey`; returns null for keys that don't match the shape. */
|
|
75
|
+
function parseJournalKey(key) {
|
|
76
|
+
if (!key.startsWith(exports.JOURNAL_PREFIX) || !key.endsWith(".json")) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const name = key.slice(exports.JOURNAL_PREFIX.length, -".json".length);
|
|
80
|
+
const separator = name.indexOf("__");
|
|
81
|
+
if (separator <= 0) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const courseSlug = name.slice(0, separator);
|
|
85
|
+
const publishId = name.slice(separator + 2);
|
|
86
|
+
if (!courseSlug || !publishId) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return { courseSlug, publishId, key };
|
|
90
|
+
}
|
|
91
|
+
function emptyStages() {
|
|
92
|
+
const stages = {};
|
|
93
|
+
for (const name of exports.STAGE_NAMES) {
|
|
94
|
+
stages[name] = { status: "pending" };
|
|
95
|
+
}
|
|
96
|
+
return stages;
|
|
97
|
+
}
|
|
98
|
+
/** Persist the journal. Never throws: bookkeeping must not break a publish. */
|
|
99
|
+
async function persist(storage, journal) {
|
|
100
|
+
try {
|
|
101
|
+
await storage.save(journalKey(journal.courseSlug, journal.publishId), JSON.stringify(journal, null, 2));
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
console.warn(`[publish-journal] Could not persist journal for "${journal.courseSlug}":`, error.message);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Start a journal for a new publication. The generated `publishId` correlates
|
|
109
|
+
* the two publish events and is returned even if persisting failed.
|
|
110
|
+
*/
|
|
111
|
+
async function createJournal(storage, courseSlug, publishId = (0, uuid_1.v4)()) {
|
|
112
|
+
const now = new Date().toISOString();
|
|
113
|
+
const journal = {
|
|
114
|
+
publishId,
|
|
115
|
+
courseSlug,
|
|
116
|
+
startedAt: now,
|
|
117
|
+
updatedAt: now,
|
|
118
|
+
attempts: 0,
|
|
119
|
+
stages: emptyStages(),
|
|
120
|
+
};
|
|
121
|
+
await persist(storage, journal);
|
|
122
|
+
return journal;
|
|
123
|
+
}
|
|
124
|
+
/** Update one stage and persist. Mutates and returns the same journal object. */
|
|
125
|
+
async function markStage(storage, journal, stage, status, extra = {}) {
|
|
126
|
+
const next = { status, at: new Date().toISOString() };
|
|
127
|
+
if (extra.error !== undefined) {
|
|
128
|
+
next.error = extra.error;
|
|
129
|
+
}
|
|
130
|
+
if (extra.meta !== undefined) {
|
|
131
|
+
next.meta = extra.meta;
|
|
132
|
+
}
|
|
133
|
+
journal.stages[stage] = next;
|
|
134
|
+
journal.updatedAt = next.at;
|
|
135
|
+
await persist(storage, journal);
|
|
136
|
+
return journal;
|
|
137
|
+
}
|
|
138
|
+
/** Read a journal. Returns null when missing or unreadable. */
|
|
139
|
+
async function readJournal(storage, courseSlug, publishId) {
|
|
140
|
+
return readJournalByKey(storage, journalKey(courseSlug, publishId));
|
|
141
|
+
}
|
|
142
|
+
async function readJournalByKey(storage, key) {
|
|
143
|
+
try {
|
|
144
|
+
const raw = await storage.read(key);
|
|
145
|
+
if (!raw) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const parsed = JSON.parse(raw);
|
|
149
|
+
if (!parsed || !parsed.publishId || !parsed.stages) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
// Tolerate journals written before a stage was added to STAGE_NAMES.
|
|
153
|
+
for (const name of exports.STAGE_NAMES) {
|
|
154
|
+
if (!parsed.stages[name]) {
|
|
155
|
+
parsed.stages[name] = { status: "pending" };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return parsed;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
console.warn(`[publish-journal] Could not read journal "${key}":`, error.message);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Every journal still stored, i.e. every publication that did not finish
|
|
167
|
+
* cleanly. Returns an empty list on error (the sweep falls back to its deep
|
|
168
|
+
* scan rather than crashing).
|
|
169
|
+
*/
|
|
170
|
+
async function listJournalRefs(storage) {
|
|
171
|
+
return listRefsByPrefix(storage, exports.JOURNAL_PREFIX);
|
|
172
|
+
}
|
|
173
|
+
/** Journals of a single course; backs the concurrent-publish guard. */
|
|
174
|
+
async function listJournalRefsForCourse(storage, courseSlug) {
|
|
175
|
+
return listRefsByPrefix(storage, coursePrefix(courseSlug));
|
|
176
|
+
}
|
|
177
|
+
async function listRefsByPrefix(storage, prefix) {
|
|
178
|
+
try {
|
|
179
|
+
const keys = await storage.list(prefix);
|
|
180
|
+
const refs = [];
|
|
181
|
+
for (const key of keys) {
|
|
182
|
+
const ref = parseJournalKey(key);
|
|
183
|
+
if (ref) {
|
|
184
|
+
refs.push(ref);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return refs;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
console.warn(`[publish-journal] Could not list journals "${prefix}":`, error.message);
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function isStageTerminal(stage) {
|
|
195
|
+
return stage.status === "done" || stage.status === "failed";
|
|
196
|
+
}
|
|
197
|
+
function hasFailedStage(journal) {
|
|
198
|
+
return exports.STAGE_NAMES.some(name => journal.stages[name].status === "failed");
|
|
199
|
+
}
|
|
200
|
+
/** True when no stage is left to run (all done, or done/failed mix). */
|
|
201
|
+
function isJournalSettled(journal) {
|
|
202
|
+
return exports.STAGE_NAMES.every(name => isStageTerminal(journal.stages[name]));
|
|
203
|
+
}
|
|
204
|
+
/** Stages the sweep still has work to do on. */
|
|
205
|
+
function pendingStages(journal) {
|
|
206
|
+
return exports.STAGE_NAMES.filter(name => !isStageTerminal(journal.stages[name]));
|
|
207
|
+
}
|
|
208
|
+
/** A journal untouched for longer than `maxAgeMs` is presumed interrupted. */
|
|
209
|
+
function isJournalStale(journal, maxAgeMs, now = Date.now()) {
|
|
210
|
+
const updatedAt = Date.parse(journal.updatedAt);
|
|
211
|
+
if (Number.isNaN(updatedAt)) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
return now - updatedAt > maxAgeMs;
|
|
215
|
+
}
|
|
216
|
+
async function incrementAttempts(storage, journal) {
|
|
217
|
+
journal.attempts += 1;
|
|
218
|
+
journal.updatedAt = new Date().toISOString();
|
|
219
|
+
await persist(storage, journal);
|
|
220
|
+
return journal;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Give up on a journal after too many attempts. It is kept, not deleted: a
|
|
224
|
+
* publication that never completed is exactly what someone needs to see.
|
|
225
|
+
*/
|
|
226
|
+
async function abandonJournal(storage, journal) {
|
|
227
|
+
journal.abandoned = true;
|
|
228
|
+
journal.updatedAt = new Date().toISOString();
|
|
229
|
+
await persist(storage, journal);
|
|
230
|
+
return journal;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Close a journal: delete it when every stage succeeded, keep it otherwise so
|
|
234
|
+
* the sweep can retry and a human can inspect it. Never throws.
|
|
235
|
+
*/
|
|
236
|
+
async function finalizeJournal(storage, journal) {
|
|
237
|
+
if (!isJournalSettled(journal) || hasFailedStage(journal)) {
|
|
238
|
+
await persist(storage, journal);
|
|
239
|
+
return "kept";
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
await storage.remove(journalKey(journal.courseSlug, journal.publishId));
|
|
243
|
+
return "deleted";
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
console.warn(`[publish-journal] Could not delete journal for "${journal.courseSlug}":`, error.message);
|
|
247
|
+
return "kept";
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** GCS-backed adapter. Object-level operations only, by design. */
|
|
251
|
+
function createGcsJournalStorage(bucket) {
|
|
252
|
+
return {
|
|
253
|
+
async save(key, contents) {
|
|
254
|
+
await bucket.file(key).save(Buffer.from(contents, "utf8"), {
|
|
255
|
+
contentType: "application/json",
|
|
256
|
+
});
|
|
257
|
+
},
|
|
258
|
+
async read(key) {
|
|
259
|
+
try {
|
|
260
|
+
const [buf] = await bucket.file(key).download();
|
|
261
|
+
return buf.toString();
|
|
262
|
+
}
|
|
263
|
+
catch (_a) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
async remove(key) {
|
|
268
|
+
await bucket.file(key).delete();
|
|
269
|
+
},
|
|
270
|
+
async list(prefix) {
|
|
271
|
+
const [files] = await bucket.getFiles({ prefix });
|
|
272
|
+
return files.map(file => file.name);
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
@@ -99,6 +99,50 @@ type TInitialContentGeneratorInputs = {
|
|
|
99
99
|
topic_description?: string;
|
|
100
100
|
};
|
|
101
101
|
export declare const initialContentGenerator: (token: string, inputs: TInitialContentGeneratorInputs, webhookUrl?: string, endpointSlug?: string) => Promise<any>;
|
|
102
|
+
export type TGenerateStepDescriptionsParams = {
|
|
103
|
+
/** README of the step, in `sourceLanguage`. Images are stripped before sending. */
|
|
104
|
+
readmeContent: string;
|
|
105
|
+
sourceLanguage: string;
|
|
106
|
+
/** Every language to produce; may or may not include `sourceLanguage`. */
|
|
107
|
+
outputLanguages: string[];
|
|
108
|
+
lessonTitle: string;
|
|
109
|
+
courseTitle?: string;
|
|
110
|
+
targetWordCount?: number;
|
|
111
|
+
promptVersion?: number;
|
|
112
|
+
};
|
|
113
|
+
export type TGenerateStepDescriptionsResult = {
|
|
114
|
+
/**
|
|
115
|
+
* Descriptions keyed by the `language_code` Rigobot answered with — building
|
|
116
|
+
* this record IS the reassociation, the response order is not contractual.
|
|
117
|
+
* A `null` value is the deliberate fallback for content too thin to describe.
|
|
118
|
+
*/
|
|
119
|
+
descriptionsByLanguage: Record<string, string | null>;
|
|
120
|
+
/** Requested languages absent from the response (a protocol problem, not a
|
|
121
|
+
* "no description" answer: callers should retry these rather than storing a
|
|
122
|
+
* null that would look settled). */
|
|
123
|
+
missingLanguages: string[];
|
|
124
|
+
promptVersion: number;
|
|
125
|
+
/** Seconds the completion took; worth logging to watch the 30s Heroku edge. */
|
|
126
|
+
durationSeconds: number | null;
|
|
127
|
+
completionId: number | null;
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Generate one short description per language for a single step.
|
|
131
|
+
*
|
|
132
|
+
* One request per step: the eye-catching saving is on the *languages* axis (one
|
|
133
|
+
* README in, N descriptions out, ~67% fewer input tokens for a typical course),
|
|
134
|
+
* while batching several steps together would save nothing and would cost cache
|
|
135
|
+
* granularity and partial results.
|
|
136
|
+
*
|
|
137
|
+
* Synchronous by design (`execute_async: false`): the caller owns the write, so
|
|
138
|
+
* every flow — the post-publish stage, the sweep and the backfill — persists
|
|
139
|
+
* its own results instead of relying on a callback. Callers throttle with a
|
|
140
|
+
* concurrency pool.
|
|
141
|
+
*
|
|
142
|
+
* All inputs travel as strings: Rigobot fills the template with plain string
|
|
143
|
+
* replacement and a non-string raises before reaching the LLM.
|
|
144
|
+
*/
|
|
145
|
+
export declare const generateStepDescriptions: (token: string, params: TGenerateStepDescriptionsParams, endpointSlug?: string) => Promise<TGenerateStepDescriptionsResult | null>;
|
|
102
146
|
type TAddInteractivityInputs = {
|
|
103
147
|
components: string;
|
|
104
148
|
prev_lesson: string;
|
package/lib/utils/rigoActions.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.generateStepSlug = exports.generateCodeChallenge = exports.addInteractivity = exports.initialContentGenerator = exports.getLanguageCodes = exports.isPackageAuthor = exports.fillSidebarJSON = exports.generateCourseShortName = exports.isValidRigoToken = exports.translateCourseMetadata = exports.createStructuredPreviewReadme = exports.readmeCreator = exports.createCodingReadme = exports.createCodeFile = exports.interactiveCreation = exports.generateCourseIntroduction = exports.translateExercise = exports.generateImage = exports.hasCreatorPermission = exports.createReadme = void 0;
|
|
3
|
+
exports.generateStepSlug = exports.generateCodeChallenge = exports.addInteractivity = exports.generateStepDescriptions = exports.initialContentGenerator = exports.getLanguageCodes = exports.isPackageAuthor = exports.fillSidebarJSON = exports.generateCourseShortName = exports.isValidRigoToken = exports.translateCourseMetadata = exports.createStructuredPreviewReadme = exports.readmeCreator = exports.createCodingReadme = exports.createCodeFile = exports.interactiveCreation = exports.generateCourseIntroduction = exports.translateExercise = exports.generateImage = exports.hasCreatorPermission = exports.createReadme = void 0;
|
|
4
4
|
exports.downloadImage = downloadImage;
|
|
5
5
|
exports.createPreviewReadme = createPreviewReadme;
|
|
6
6
|
exports.makeReadmeReadable = makeReadmeReadable;
|
|
@@ -10,6 +10,8 @@ const console_1 = require("../utils/console");
|
|
|
10
10
|
const fs = require("fs");
|
|
11
11
|
const path = require("path");
|
|
12
12
|
const api_1 = require("./api");
|
|
13
|
+
const descriptionHash_1 = require("./descriptionHash");
|
|
14
|
+
const packageManifest_1 = require("./packageManifest");
|
|
13
15
|
const createReadme = async (token, inputs, purpose, webhookUrl) => {
|
|
14
16
|
try {
|
|
15
17
|
const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/create-sequencial-readme/`, {
|
|
@@ -304,6 +306,78 @@ const initialContentGenerator = async (token, inputs, webhookUrl, endpointSlug =
|
|
|
304
306
|
}
|
|
305
307
|
};
|
|
306
308
|
exports.initialContentGenerator = initialContentGenerator;
|
|
309
|
+
/**
|
|
310
|
+
* Generate one short description per language for a single step.
|
|
311
|
+
*
|
|
312
|
+
* One request per step: the eye-catching saving is on the *languages* axis (one
|
|
313
|
+
* README in, N descriptions out, ~67% fewer input tokens for a typical course),
|
|
314
|
+
* while batching several steps together would save nothing and would cost cache
|
|
315
|
+
* granularity and partial results.
|
|
316
|
+
*
|
|
317
|
+
* Synchronous by design (`execute_async: false`): the caller owns the write, so
|
|
318
|
+
* every flow — the post-publish stage, the sweep and the backfill — persists
|
|
319
|
+
* its own results instead of relying on a callback. Callers throttle with a
|
|
320
|
+
* concurrency pool.
|
|
321
|
+
*
|
|
322
|
+
* All inputs travel as strings: Rigobot fills the template with plain string
|
|
323
|
+
* replacement and a non-string raises before reaching the LLM.
|
|
324
|
+
*/
|
|
325
|
+
const generateStepDescriptions = async (token, params, endpointSlug = "generate-step-descriptions") => {
|
|
326
|
+
var _a, _b, _c, _d, _e;
|
|
327
|
+
const promptVersion = (_a = params.promptVersion) !== null && _a !== void 0 ? _a : packageManifest_1.DESCRIPTION_PROMPT_VERSION;
|
|
328
|
+
const targetWordCount = (_b = params.targetWordCount) !== null && _b !== void 0 ? _b : packageManifest_1.DESCRIPTION_TARGET_WORD_COUNT;
|
|
329
|
+
const inputs = {
|
|
330
|
+
// Images never help a description and their alt text may be a whole
|
|
331
|
+
// image-generation prompt; markdown structure is kept, it helps the model.
|
|
332
|
+
readme_content: (0, descriptionHash_1.stripMarkdownImages)(params.readmeContent),
|
|
333
|
+
source_language: params.sourceLanguage,
|
|
334
|
+
output_languages: params.outputLanguages.join(","),
|
|
335
|
+
lesson_title: params.lessonTitle,
|
|
336
|
+
course_title: (_c = params.courseTitle) !== null && _c !== void 0 ? _c : "",
|
|
337
|
+
target_word_count: String(targetWordCount),
|
|
338
|
+
prompt_version: String(promptVersion),
|
|
339
|
+
};
|
|
340
|
+
try {
|
|
341
|
+
const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/${endpointSlug}/`, { inputs, execute_async: false }, {
|
|
342
|
+
headers: {
|
|
343
|
+
"Content-Type": "application/json",
|
|
344
|
+
Authorization: "Token " + token.trim(),
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
const data = response.data;
|
|
348
|
+
if (!data || data.status === "ERROR") {
|
|
349
|
+
console.error("Error in generateStepDescriptions:", (data === null || data === void 0 ? void 0 : data.status_text) || "completion returned ERROR");
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
const entries = Array.isArray((_d = data.parsed) === null || _d === void 0 ? void 0 : _d.descriptions) ?
|
|
353
|
+
data.parsed.descriptions :
|
|
354
|
+
[];
|
|
355
|
+
const descriptionsByLanguage = {};
|
|
356
|
+
for (const entry of entries) {
|
|
357
|
+
const code = entry === null || entry === void 0 ? void 0 : entry.language_code;
|
|
358
|
+
if (typeof code !== "string" || !code) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
descriptionsByLanguage[code] =
|
|
362
|
+
typeof entry.description === "string" ? entry.description : null;
|
|
363
|
+
}
|
|
364
|
+
const echoedVersion = Number.parseInt((_e = data.inputs) === null || _e === void 0 ? void 0 : _e.prompt_version, 10);
|
|
365
|
+
return {
|
|
366
|
+
descriptionsByLanguage,
|
|
367
|
+
missingLanguages: params.outputLanguages.filter(lang => !(lang in descriptionsByLanguage)),
|
|
368
|
+
promptVersion: Number.isFinite(echoedVersion) ?
|
|
369
|
+
echoedVersion :
|
|
370
|
+
promptVersion,
|
|
371
|
+
durationSeconds: typeof data.duration === "number" ? data.duration : null,
|
|
372
|
+
completionId: typeof data.id === "number" ? data.id : null,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
console.error("Error in generateStepDescriptions:", error);
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
exports.generateStepDescriptions = generateStepDescriptions;
|
|
307
381
|
const addInteractivity = async (token, inputs, webhookUrl) => {
|
|
308
382
|
try {
|
|
309
383
|
const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/add-lesson-interactivy/`, {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Exercise } from "../configBuilder";
|
|
2
|
+
import { Syllabus } from "../../models/creator";
|
|
3
|
+
import { PackageManifest, ReadmeFrontmatter } from "../packageManifest";
|
|
4
|
+
export type PackageResultStatus = "created" | "updated" | "skipped" | "failed";
|
|
5
|
+
export type PackageResult = {
|
|
6
|
+
slug: string;
|
|
7
|
+
status: PackageResultStatus;
|
|
8
|
+
durationMs: number;
|
|
9
|
+
error?: string;
|
|
10
|
+
step?: string;
|
|
11
|
+
sources?: {
|
|
12
|
+
learnJson: boolean;
|
|
13
|
+
configJson: boolean;
|
|
14
|
+
sidebar: boolean;
|
|
15
|
+
syllabus: boolean;
|
|
16
|
+
readmes: number;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
export type BackfillOptions = {
|
|
20
|
+
dryRun: boolean;
|
|
21
|
+
force: boolean;
|
|
22
|
+
skipExisting: boolean;
|
|
23
|
+
noInvalidate: boolean;
|
|
24
|
+
concurrency: number;
|
|
25
|
+
publishedAt: string | null;
|
|
26
|
+
resumeFrom?: string;
|
|
27
|
+
reportOut: string;
|
|
28
|
+
slug?: string;
|
|
29
|
+
bucket: string;
|
|
30
|
+
region: string;
|
|
31
|
+
cloudFrontDistributionId?: string;
|
|
32
|
+
};
|
|
33
|
+
export type BatchReport = {
|
|
34
|
+
total: number;
|
|
35
|
+
created: number;
|
|
36
|
+
updated: number;
|
|
37
|
+
skipped: number;
|
|
38
|
+
failed: number;
|
|
39
|
+
startedAt: string;
|
|
40
|
+
finishedAt: string;
|
|
41
|
+
options: BackfillOptions;
|
|
42
|
+
results: PackageResult[];
|
|
43
|
+
invalidation?: {
|
|
44
|
+
attempted: boolean;
|
|
45
|
+
skippedReason?: string;
|
|
46
|
+
id?: string;
|
|
47
|
+
paths?: string[];
|
|
48
|
+
error?: string;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
export type AwsClient = {
|
|
52
|
+
send(command: unknown): Promise<unknown>;
|
|
53
|
+
};
|
|
54
|
+
type PackageSources = {
|
|
55
|
+
learnJson: Record<string, unknown>;
|
|
56
|
+
exercises: Exercise[];
|
|
57
|
+
sidebar: Record<string, Record<string, string>>;
|
|
58
|
+
syllabus: Syllabus | null;
|
|
59
|
+
readmeFrontmatters: Record<string, Record<string, ReadmeFrontmatter>>;
|
|
60
|
+
meta: PackageResult["sources"];
|
|
61
|
+
};
|
|
62
|
+
export declare function streamToString(body: unknown): Promise<string>;
|
|
63
|
+
export declare function withRetry<T>(fn: () => Promise<T>, attempts?: number, attempt?: number): Promise<T>;
|
|
64
|
+
export declare function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>;
|
|
65
|
+
export declare function listPublishedSlugs(s3: AwsClient, bucket: string): Promise<string[]>;
|
|
66
|
+
export declare function fetchJsonObject<T>(s3: AwsClient, bucket: string, key: string): Promise<T | null>;
|
|
67
|
+
export declare function fetchTextObject(s3: AwsClient, bucket: string, key: string): Promise<string | null>;
|
|
68
|
+
export declare function listObjectKeys(s3: AwsClient, bucket: string, prefix: string): Promise<string[]>;
|
|
69
|
+
export declare function fetchPackageSources(s3: AwsClient, bucket: string, slug: string): Promise<PackageSources>;
|
|
70
|
+
export declare function buildManifestFromS3Sources(slug: string, sources: PackageSources, options: {
|
|
71
|
+
publishedAt: string | null;
|
|
72
|
+
generatedAt?: string;
|
|
73
|
+
}): PackageManifest;
|
|
74
|
+
export declare function processPackage(s3: AwsClient, bucket: string, slug: string, options: Pick<BackfillOptions, "dryRun" | "force" | "skipExisting" | "publishedAt">): Promise<PackageResult>;
|
|
75
|
+
export declare function invalidatePackageManifestPaths(cf: AwsClient, distributionId: string, paths: string[]): Promise<{
|
|
76
|
+
id: string;
|
|
77
|
+
}>;
|
|
78
|
+
export declare function runBatch(options: BackfillOptions, clients?: {
|
|
79
|
+
s3?: AwsClient;
|
|
80
|
+
cf?: AwsClient;
|
|
81
|
+
onProgress?: (index: number, total: number, result: PackageResult) => void;
|
|
82
|
+
}): Promise<BatchReport>;
|
|
83
|
+
export declare function getBatchExitCode(report: BatchReport): number;
|
|
84
|
+
export {};
|