@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,193 @@
|
|
|
1
|
+
/* eslint-disable no-await-in-loop */
|
|
2
|
+
/**
|
|
3
|
+
* Fase 0b — GCS descriptions backfill (reuse from S3, no LLM).
|
|
4
|
+
*
|
|
5
|
+
* Seeds the GCS (draft) syllabus with descriptions already generated for the
|
|
6
|
+
* published S3 syllabus (Fase 0a) — a pure COPY, guarded by the content hash,
|
|
7
|
+
* performed by the shared mirror (`mirrorDescriptionsToDraft`). It NEVER calls
|
|
8
|
+
* the LLM: the ~800 GCS drafts are mostly unused, so anything not reusable from
|
|
9
|
+
* S3 is left for the next publication of that course (or a single-slug 0a run).
|
|
10
|
+
* Run AFTER 0a has completed.
|
|
11
|
+
*
|
|
12
|
+
* Batched like 0a: --limit courses per run (default 50), ordered by
|
|
13
|
+
* initialSyllabus modification time (newest first).
|
|
14
|
+
*
|
|
15
|
+
* Secrets/infra via env: GCP_CREDENTIALS_JSON (required), plus
|
|
16
|
+
* S3_PACKAGES_BUCKET / GCP_BUCKET_NAME / AWS_REGION as fallbacks for the
|
|
17
|
+
* corresponding flags.
|
|
18
|
+
*
|
|
19
|
+
* Per-run flags:
|
|
20
|
+
* --s3-bucket <name> published S3 bucket to reuse from (or env S3_PACKAGES_BUCKET)
|
|
21
|
+
* --gcs-bucket <name> GCS bucket (or env GCP_BUCKET_NAME, which is required)
|
|
22
|
+
* --region <region> AWS region (default us-east-1, or env AWS_REGION)
|
|
23
|
+
* --slug <slug> process a single course
|
|
24
|
+
* --limit <n> courses per run (default 50)
|
|
25
|
+
* --dry-run preview without writing
|
|
26
|
+
* --no-reconcile skip the additive syllabus reconciliation
|
|
27
|
+
*
|
|
28
|
+
* Examples:
|
|
29
|
+
* node lib/scripts/descriptionsGcsBackfill.js --limit 200
|
|
30
|
+
* node lib/scripts/descriptionsGcsBackfill.js --slug my-course
|
|
31
|
+
*/
|
|
32
|
+
import { parseArgs } from "node:util"
|
|
33
|
+
import { Bucket, Storage } from "@google-cloud/storage"
|
|
34
|
+
import { S3Client } from "@aws-sdk/client-s3"
|
|
35
|
+
import { Syllabus } from "../models/creator"
|
|
36
|
+
import { synchronizeSyllabusWithBucket } from "../utils/syllabusSync"
|
|
37
|
+
import {
|
|
38
|
+
AwsClient,
|
|
39
|
+
fetchJsonObject,
|
|
40
|
+
} from "../utils/s3/packageManifestBackfill"
|
|
41
|
+
import { requireGcsBucketName } from "../utils/gcsBucketName"
|
|
42
|
+
import { createGcsDescriptionsStorage } from "../utils/descriptions/gcsStorage"
|
|
43
|
+
import { mirrorDescriptionsToDraft } from "../utils/descriptions/mirrorDescriptions"
|
|
44
|
+
import { syllabusKey as s3SyllabusKey } from "../utils/descriptions/s3Storage"
|
|
45
|
+
|
|
46
|
+
const GCS_SYLLABUS_PATTERN =
|
|
47
|
+
/^courses\/([^/]+)\/\.learn\/initialSyllabus\.json$/
|
|
48
|
+
|
|
49
|
+
/** Courses (by slug) ordered by initialSyllabus.json update time, newest first. */
|
|
50
|
+
async function listCoursesByRecency(bucket: Bucket): Promise<string[]> {
|
|
51
|
+
const [files] = await bucket.getFiles({ prefix: "courses/" })
|
|
52
|
+
const mtimes = new Map<string, number>()
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
const match = file.name.match(GCS_SYLLABUS_PATTERN)
|
|
55
|
+
if (match) {
|
|
56
|
+
const updated = (file.metadata as { updated?: string } | undefined)
|
|
57
|
+
?.updated
|
|
58
|
+
mtimes.set(match[1], updated ? new Date(updated).getTime() : 0)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return [...mtimes.entries()]
|
|
63
|
+
.sort((a, b) => b[1] - a[1])
|
|
64
|
+
.map(entry => entry[0])
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function main(): Promise<void> {
|
|
68
|
+
const { values } = parseArgs({
|
|
69
|
+
options: {
|
|
70
|
+
"s3-bucket": { type: "string" },
|
|
71
|
+
"gcs-bucket": { type: "string" },
|
|
72
|
+
region: { type: "string" },
|
|
73
|
+
slug: { type: "string" },
|
|
74
|
+
limit: { type: "string" },
|
|
75
|
+
"dry-run": { type: "boolean", default: false },
|
|
76
|
+
"no-reconcile": { type: "boolean", default: false },
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const s3Bucket =
|
|
81
|
+
values["s3-bucket"] ||
|
|
82
|
+
process.env.S3_PACKAGES_BUCKET ||
|
|
83
|
+
"learnpack-paquetes"
|
|
84
|
+
|
|
85
|
+
const credentialsEnv = process.env.GCP_CREDENTIALS_JSON
|
|
86
|
+
if (!credentialsEnv) {
|
|
87
|
+
console.error("[gcs-backfill] GCP_CREDENTIALS_JSON (env) is required")
|
|
88
|
+
process.exit(1)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const bucket = new Storage({
|
|
92
|
+
credentials: JSON.parse(credentialsEnv),
|
|
93
|
+
}).bucket(values["gcs-bucket"] || requireGcsBucketName())
|
|
94
|
+
|
|
95
|
+
const s3 = new S3Client({
|
|
96
|
+
region: values.region || process.env.AWS_REGION || "us-east-1",
|
|
97
|
+
}) as unknown as AwsClient
|
|
98
|
+
|
|
99
|
+
const dryRun = values["dry-run"] ?? false
|
|
100
|
+
const reconcile = !(values["no-reconcile"] ?? false)
|
|
101
|
+
const limit = Number.parseInt(values.limit || "50", 10) || 50
|
|
102
|
+
const storage = createGcsDescriptionsStorage(bucket)
|
|
103
|
+
|
|
104
|
+
const singleSlug = values.slug
|
|
105
|
+
const slugs = singleSlug ? [singleSlug] : await listCoursesByRecency(bucket)
|
|
106
|
+
|
|
107
|
+
console.log(
|
|
108
|
+
`[gcs-backfill] Starting${
|
|
109
|
+
singleSlug ? ` (slug=${singleSlug})` : ` (limit=${limit})`
|
|
110
|
+
}${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)`
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
let processed = 0
|
|
114
|
+
let copied = 0
|
|
115
|
+
let missed = 0
|
|
116
|
+
|
|
117
|
+
for (const slug of slugs) {
|
|
118
|
+
if (!singleSlug && processed >= limit) {
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const publishedSyllabus = await fetchJsonObject<Syllabus>(
|
|
124
|
+
s3,
|
|
125
|
+
s3Bucket,
|
|
126
|
+
s3SyllabusKey(slug)
|
|
127
|
+
)
|
|
128
|
+
if (!publishedSyllabus) {
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (reconcile && storage.syllabusSyncStorage) {
|
|
133
|
+
const draft = await storage.readSyllabus(slug)
|
|
134
|
+
if (draft) {
|
|
135
|
+
try {
|
|
136
|
+
const sync = await synchronizeSyllabusWithBucket(
|
|
137
|
+
storage.syllabusSyncStorage,
|
|
138
|
+
slug,
|
|
139
|
+
draft,
|
|
140
|
+
{ prune: false }
|
|
141
|
+
)
|
|
142
|
+
if (sync.changed && !dryRun) {
|
|
143
|
+
await storage.writeSyllabus(slug, sync.syllabus)
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.error(
|
|
147
|
+
`[gcs-backfill] Reconciliation failed for "${slug}":`,
|
|
148
|
+
(error as Error).message
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const result = await mirrorDescriptionsToDraft(
|
|
155
|
+
storage,
|
|
156
|
+
slug,
|
|
157
|
+
publishedSyllabus,
|
|
158
|
+
{ dryRun }
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if (result.copied === 0 && result.missed === 0) {
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
processed += 1
|
|
166
|
+
copied += result.copied
|
|
167
|
+
missed += result.missed
|
|
168
|
+
|
|
169
|
+
console.log(
|
|
170
|
+
`[gcs-backfill] "${slug}": copied ${result.copied}, ${
|
|
171
|
+
result.missed
|
|
172
|
+
} miss(es)${dryRun ? " (dry-run)" : ""}`
|
|
173
|
+
)
|
|
174
|
+
} catch (error) {
|
|
175
|
+
processed += 1
|
|
176
|
+
console.error(
|
|
177
|
+
`[gcs-backfill] Failed processing "${slug}":`,
|
|
178
|
+
(error as Error).message
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
console.log(
|
|
184
|
+
`[gcs-backfill] Done. ${processed} course(s), ${copied} description(s) copied, ${missed} miss(es).`
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
main()
|
|
189
|
+
.then(() => process.exit(0))
|
|
190
|
+
.catch(error => {
|
|
191
|
+
console.error("[gcs-backfill] Fatal error:", error)
|
|
192
|
+
process.exit(1)
|
|
193
|
+
})
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/* eslint-disable no-await-in-loop */
|
|
2
|
+
/**
|
|
3
|
+
* Fase 0a — S3 descriptions backfill (step 2 of the S3 pipeline).
|
|
4
|
+
*
|
|
5
|
+
* One-off fill of the published catalogue (~400+ courses). The per-course work
|
|
6
|
+
* is the shared description service (`generateCourseDescriptions`), the same one
|
|
7
|
+
* the post-publish stage and the sweep use; this script only decides WHICH
|
|
8
|
+
* courses to run it on and in what batches.
|
|
9
|
+
*
|
|
10
|
+
* - Processes at most --limit courses per run (default 50), ordered by the
|
|
11
|
+
* initialSyllabus.json modification time (most recently edited first),
|
|
12
|
+
* skipping courses already settled at the current prompt version. Re-run to
|
|
13
|
+
* advance through the catalogue.
|
|
14
|
+
* - Or target a single course with --slug (remediation / on-demand).
|
|
15
|
+
*
|
|
16
|
+
* Writes descriptions into the published initialSyllabus.json. Manifest
|
|
17
|
+
* projection is a separate pass here: run the package-manifest backfill
|
|
18
|
+
* (runBatch) afterwards, which is cheaper than re-projecting course by course.
|
|
19
|
+
*
|
|
20
|
+
* Secrets/infra via env: RIGOBOT_SYSTEM_TOKEN (required), plus S3_PACKAGES_BUCKET
|
|
21
|
+
* and AWS_REGION as fallbacks for --s3-bucket / --region.
|
|
22
|
+
*
|
|
23
|
+
* Per-run flags:
|
|
24
|
+
* --s3-bucket <name> S3 bucket (or env S3_PACKAGES_BUCKET, default learnpack-paquetes)
|
|
25
|
+
* --region <region> AWS region (default us-east-1, or env AWS_REGION)
|
|
26
|
+
* --slug <slug> process a single course (remediation / on-demand)
|
|
27
|
+
* --limit <n> courses per run (default 50)
|
|
28
|
+
* --target-words <n> words per description (default 25)
|
|
29
|
+
* --concurrency <n> completions in flight per course (default 5)
|
|
30
|
+
* --dry-run preview without writing/generating
|
|
31
|
+
* --no-reconcile skip the additive syllabus reconciliation
|
|
32
|
+
* --force regenerate even courses already settled at this prompt version
|
|
33
|
+
*
|
|
34
|
+
* Examples:
|
|
35
|
+
* node lib/scripts/descriptionsS3Backfill.js --limit 50
|
|
36
|
+
* node lib/scripts/descriptionsS3Backfill.js --slug my-course
|
|
37
|
+
* node lib/scripts/descriptionsS3Backfill.js --dry-run --limit 10
|
|
38
|
+
*/
|
|
39
|
+
import { parseArgs } from "node:util"
|
|
40
|
+
import { ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3"
|
|
41
|
+
import { DESCRIPTION_TARGET_WORD_COUNT } from "../utils/packageManifest"
|
|
42
|
+
import { AwsClient } from "../utils/s3/packageManifestBackfill"
|
|
43
|
+
import {
|
|
44
|
+
DEFAULT_DESCRIPTIONS_CONCURRENCY,
|
|
45
|
+
generateCourseDescriptions,
|
|
46
|
+
} from "../utils/descriptions/generateCourseDescriptions"
|
|
47
|
+
import {
|
|
48
|
+
createS3DescriptionsStorage,
|
|
49
|
+
fetchExercises,
|
|
50
|
+
} from "../utils/descriptions/s3Storage"
|
|
51
|
+
import { isCourseSettled } from "../utils/descriptions/workList"
|
|
52
|
+
|
|
53
|
+
const SYLLABUS_KEY_PATTERN = /^([^/]+)\/\.learn\/initialSyllabus\.json$/
|
|
54
|
+
|
|
55
|
+
/** Courses (by slug) ordered by initialSyllabus.json LastModified, newest first. */
|
|
56
|
+
async function listCoursesByRecency(
|
|
57
|
+
s3: AwsClient,
|
|
58
|
+
bucket: string
|
|
59
|
+
): Promise<string[]> {
|
|
60
|
+
const mtimes = new Map<string, number>()
|
|
61
|
+
let continuationToken: string | undefined
|
|
62
|
+
|
|
63
|
+
do {
|
|
64
|
+
const response = (await s3.send(
|
|
65
|
+
new ListObjectsV2Command({
|
|
66
|
+
Bucket: bucket,
|
|
67
|
+
ContinuationToken: continuationToken,
|
|
68
|
+
})
|
|
69
|
+
)) as {
|
|
70
|
+
Contents?: Array<{ Key?: string; LastModified?: Date }>;
|
|
71
|
+
NextContinuationToken?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const object of response.Contents || []) {
|
|
75
|
+
const match = object.Key?.match(SYLLABUS_KEY_PATTERN)
|
|
76
|
+
if (match) {
|
|
77
|
+
mtimes.set(match[1], object.LastModified?.getTime() || 0)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
continuationToken = response.NextContinuationToken
|
|
82
|
+
} while (continuationToken)
|
|
83
|
+
|
|
84
|
+
return [...mtimes.entries()]
|
|
85
|
+
.sort((a, b) => b[1] - a[1])
|
|
86
|
+
.map(([slug]) => slug)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function main(): Promise<void> {
|
|
90
|
+
const { values } = parseArgs({
|
|
91
|
+
options: {
|
|
92
|
+
"s3-bucket": { type: "string" },
|
|
93
|
+
region: { type: "string" },
|
|
94
|
+
slug: { type: "string" },
|
|
95
|
+
limit: { type: "string" },
|
|
96
|
+
"target-words": { type: "string" },
|
|
97
|
+
concurrency: { type: "string" },
|
|
98
|
+
"dry-run": { type: "boolean", default: false },
|
|
99
|
+
"no-reconcile": { type: "boolean", default: false },
|
|
100
|
+
force: { type: "boolean", default: false },
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const token = process.env.RIGOBOT_SYSTEM_TOKEN
|
|
105
|
+
if (!token) {
|
|
106
|
+
console.error("[s3-backfill] RIGOBOT_SYSTEM_TOKEN (env) is required")
|
|
107
|
+
process.exit(1)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const bucket =
|
|
111
|
+
values["s3-bucket"] ||
|
|
112
|
+
process.env.S3_PACKAGES_BUCKET ||
|
|
113
|
+
"learnpack-paquetes"
|
|
114
|
+
|
|
115
|
+
const region = values.region || process.env.AWS_REGION || "us-east-1"
|
|
116
|
+
const limit = Number.parseInt(values.limit || "50", 10) || 50
|
|
117
|
+
const dryRun = values["dry-run"] ?? false
|
|
118
|
+
const force = values.force ?? false
|
|
119
|
+
|
|
120
|
+
const s3 = new S3Client({ region }) as unknown as AwsClient
|
|
121
|
+
// Manifests are projected in a separate pass after this backfill.
|
|
122
|
+
const storage = createS3DescriptionsStorage(s3, bucket, {
|
|
123
|
+
reprojectManifest: false,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const singleSlug = values.slug
|
|
127
|
+
const slugs = singleSlug ?
|
|
128
|
+
[singleSlug] :
|
|
129
|
+
await listCoursesByRecency(s3, bucket)
|
|
130
|
+
|
|
131
|
+
console.log(
|
|
132
|
+
`[s3-backfill] Starting${
|
|
133
|
+
singleSlug ? ` (slug=${singleSlug})` : ` (limit=${limit})`
|
|
134
|
+
}${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)`
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
let processed = 0
|
|
138
|
+
let generated = 0
|
|
139
|
+
let failed = 0
|
|
140
|
+
|
|
141
|
+
for (const slug of slugs) {
|
|
142
|
+
if (!singleSlug && processed >= limit) {
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
// Cheap pre-filter: skip finished courses without reading their READMEs.
|
|
148
|
+
if (!force) {
|
|
149
|
+
const syllabus = await storage.readSyllabus(slug)
|
|
150
|
+
if (!syllabus) {
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const exercises = await fetchExercises(s3, bucket, slug)
|
|
155
|
+
if (exercises.length === 0 || isCourseSettled(syllabus, exercises)) {
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const result = await generateCourseDescriptions(storage, slug, {
|
|
161
|
+
token,
|
|
162
|
+
dryRun,
|
|
163
|
+
force,
|
|
164
|
+
reconcile: !(values["no-reconcile"] ?? false),
|
|
165
|
+
concurrency:
|
|
166
|
+
Number.parseInt(values.concurrency || "", 10) ||
|
|
167
|
+
DEFAULT_DESCRIPTIONS_CONCURRENCY,
|
|
168
|
+
targetWordCount:
|
|
169
|
+
Number.parseInt(values["target-words"] || "", 10) ||
|
|
170
|
+
DESCRIPTION_TARGET_WORD_COUNT,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
if (result.status === "skipped") {
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
processed += 1
|
|
178
|
+
generated += result.generated
|
|
179
|
+
failed += result.failed
|
|
180
|
+
|
|
181
|
+
console.log(
|
|
182
|
+
`[s3-backfill] "${slug}": ${result.steps} step(s), ${
|
|
183
|
+
result.generated
|
|
184
|
+
} description(s)${dryRun ? " (dry-run)" : ""}${
|
|
185
|
+
result.failed > 0 ? `, ${result.failed} failed` : ""
|
|
186
|
+
}${result.missing > 0 ? `, ${result.missing} unanswered` : ""}`
|
|
187
|
+
)
|
|
188
|
+
} catch (error) {
|
|
189
|
+
processed += 1
|
|
190
|
+
failed += 1
|
|
191
|
+
console.error(
|
|
192
|
+
`[s3-backfill] Failed processing "${slug}":`,
|
|
193
|
+
(error as Error).message
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
console.log(
|
|
199
|
+
`[s3-backfill] Done. ${processed} course(s), ${generated} description(s) written, ${failed} failure(s).`
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
main()
|
|
204
|
+
.then(() => process.exit(0))
|
|
205
|
+
.catch(error => {
|
|
206
|
+
console.error("[s3-backfill] Fatal error:", error)
|
|
207
|
+
process.exit(1)
|
|
208
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/* eslint-disable no-await-in-loop */
|
|
2
|
+
/**
|
|
3
|
+
* Descriptions sweep — safety net for the post-publish stage.
|
|
4
|
+
*
|
|
5
|
+
* The stage that generates descriptions runs inside the web process, so a dyno
|
|
6
|
+
* restart mid-flight leaves a package published but not described and, worse,
|
|
7
|
+
* breathecode waiting for a manifest event that was promised and never arrived.
|
|
8
|
+
* This job finishes those publications.
|
|
9
|
+
*
|
|
10
|
+
* Runs as a one-off dyno via Heroku Scheduler (hourly is plenty):
|
|
11
|
+
* node lib/scripts/descriptionsSweep.js
|
|
12
|
+
*
|
|
13
|
+
* Two sources of work:
|
|
14
|
+
* 1. Publish journals (`publish-journal/` in GCS) — the primary one. Each
|
|
15
|
+
* journal that stalled is resumed: missing descriptions are generated, the
|
|
16
|
+
* draft is mirrored, and the owed event is emitted. Journals are dropped
|
|
17
|
+
* once complete and abandoned after a few attempts.
|
|
18
|
+
* 2. A deep scan of published courses (DESCRIPTIONS_SWEEP_FULL=1) — catches
|
|
19
|
+
* packages that never got a journal at all, at the cost of reading every
|
|
20
|
+
* course. Off by default.
|
|
21
|
+
*
|
|
22
|
+
* Required env: GCP_CREDENTIALS_JSON, RIGOBOT_SYSTEM_TOKEN.
|
|
23
|
+
* Optional env: GCP_BUCKET_NAME, S3_PACKAGES_BUCKET, AWS_REGION,
|
|
24
|
+
* CLOUDFRONT_DISTRIBUTION_ID, BREATHECODE_SYSTEM_TOKEN (needed to emit the
|
|
25
|
+
* pending events), DESCRIPTIONS_SWEEP_FULL, DESCRIPTIONS_SWEEP_LIMIT,
|
|
26
|
+
* DESCRIPTION_STALE_AFTER_MS.
|
|
27
|
+
*/
|
|
28
|
+
import { Storage } from "@google-cloud/storage"
|
|
29
|
+
import { S3Client } from "@aws-sdk/client-s3"
|
|
30
|
+
import {
|
|
31
|
+
createGcsJournalStorage,
|
|
32
|
+
listJournalRefs,
|
|
33
|
+
readJournalByKey,
|
|
34
|
+
} from "../utils/publishJournal"
|
|
35
|
+
import {
|
|
36
|
+
AwsClient,
|
|
37
|
+
listPublishedSlugs,
|
|
38
|
+
} from "../utils/s3/packageManifestBackfill"
|
|
39
|
+
import { requireGcsBucketName } from "../utils/gcsBucketName"
|
|
40
|
+
import { createGcsDescriptionsStorage } from "../utils/descriptions/gcsStorage"
|
|
41
|
+
import { createS3DescriptionsStorageFromEnv } from "../utils/descriptions/s3Storage"
|
|
42
|
+
import { generateCourseDescriptions } from "../utils/descriptions/generateCourseDescriptions"
|
|
43
|
+
import {
|
|
44
|
+
DEFAULT_STALE_AFTER_MS,
|
|
45
|
+
resumePublication,
|
|
46
|
+
ResumeOutcome,
|
|
47
|
+
} from "../utils/descriptions/resumePublication"
|
|
48
|
+
|
|
49
|
+
async function main(): Promise<void> {
|
|
50
|
+
const credentialsEnv = process.env.GCP_CREDENTIALS_JSON
|
|
51
|
+
if (!credentialsEnv) {
|
|
52
|
+
console.error("[sweep] GCP_CREDENTIALS_JSON (env) is required")
|
|
53
|
+
process.exit(1)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const rigobotToken = process.env.RIGOBOT_SYSTEM_TOKEN
|
|
57
|
+
if (!rigobotToken) {
|
|
58
|
+
console.error("[sweep] RIGOBOT_SYSTEM_TOKEN (env) is required")
|
|
59
|
+
process.exit(1)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const breathecodeToken = process.env.BREATHECODE_SYSTEM_TOKEN
|
|
63
|
+
if (!breathecodeToken) {
|
|
64
|
+
console.warn(
|
|
65
|
+
"[sweep] BREATHECODE_SYSTEM_TOKEN is not set: pending events cannot be emitted"
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const bucket = new Storage({
|
|
70
|
+
credentials: JSON.parse(credentialsEnv),
|
|
71
|
+
}).bucket(requireGcsBucketName())
|
|
72
|
+
|
|
73
|
+
const journalStorage = createGcsJournalStorage(bucket)
|
|
74
|
+
const publishedStorage = createS3DescriptionsStorageFromEnv()
|
|
75
|
+
const draftStorage = createGcsDescriptionsStorage(bucket)
|
|
76
|
+
const staleAfterMs =
|
|
77
|
+
Number.parseInt(process.env.DESCRIPTION_STALE_AFTER_MS || "", 10) ||
|
|
78
|
+
DEFAULT_STALE_AFTER_MS
|
|
79
|
+
|
|
80
|
+
const refs = await listJournalRefs(journalStorage)
|
|
81
|
+
console.log(`[sweep] ${refs.length} unfinished publication(s)`)
|
|
82
|
+
|
|
83
|
+
const tally: Record<ResumeOutcome, number> = {
|
|
84
|
+
"skipped-recent": 0,
|
|
85
|
+
"skipped-abandoned": 0,
|
|
86
|
+
abandoned: 0,
|
|
87
|
+
resumed: 0,
|
|
88
|
+
"nothing-to-do": 0,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const ref of refs) {
|
|
92
|
+
try {
|
|
93
|
+
const journal = await readJournalByKey(journalStorage, ref.key)
|
|
94
|
+
if (!journal) {
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const outcome = await resumePublication(journal, {
|
|
99
|
+
journalStorage,
|
|
100
|
+
publishedStorage,
|
|
101
|
+
draftStorage,
|
|
102
|
+
rigobotToken,
|
|
103
|
+
breathecodeToken,
|
|
104
|
+
staleAfterMs,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
tally[outcome] += 1
|
|
108
|
+
if (outcome === "resumed") {
|
|
109
|
+
console.log(`[sweep] Resumed "${ref.courseSlug}" (${ref.publishId})`)
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error(
|
|
113
|
+
`[sweep] Failed resuming "${ref.courseSlug}":`,
|
|
114
|
+
(error as Error).message
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(
|
|
120
|
+
`[sweep] Journals: ${tally.resumed} resumed, ${tally["skipped-recent"]} still in flight, ` +
|
|
121
|
+
`${tally["nothing-to-do"]} nothing to do, ${tally.abandoned} abandoned, ` +
|
|
122
|
+
`${tally["skipped-abandoned"]} already abandoned`
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if (process.env.DESCRIPTIONS_SWEEP_FULL === "1") {
|
|
126
|
+
await deepScan(rigobotToken, publishedStorage)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Fallback for packages with no journal at all (published before this existed,
|
|
132
|
+
* or whose journal write failed). Expensive: it reads every published course,
|
|
133
|
+
* so it is opt-in rather than the default path.
|
|
134
|
+
*/
|
|
135
|
+
async function deepScan(
|
|
136
|
+
rigobotToken: string,
|
|
137
|
+
publishedStorage: ReturnType<typeof createS3DescriptionsStorageFromEnv>
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const s3 = new S3Client({
|
|
140
|
+
region: process.env.AWS_REGION || "us-east-1",
|
|
141
|
+
}) as unknown as AwsClient
|
|
142
|
+
const bucket = process.env.S3_PACKAGES_BUCKET || "learnpack-paquetes"
|
|
143
|
+
const limit =
|
|
144
|
+
Number.parseInt(process.env.DESCRIPTIONS_SWEEP_LIMIT || "", 10) || 25
|
|
145
|
+
|
|
146
|
+
const slugs = await listPublishedSlugs(s3, bucket)
|
|
147
|
+
console.log(
|
|
148
|
+
`[sweep] Deep scan over ${slugs.length} published course(s), limit ${limit}`
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
let processed = 0
|
|
152
|
+
for (const slug of slugs) {
|
|
153
|
+
if (processed >= limit) {
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const result = await generateCourseDescriptions(publishedStorage, slug, {
|
|
159
|
+
token: rigobotToken,
|
|
160
|
+
reconcile: true,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
if (result.status === "skipped") {
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
processed += 1
|
|
168
|
+
console.log(
|
|
169
|
+
`[sweep] "${slug}": ${result.generated} description(s), ${result.failed} failed`
|
|
170
|
+
)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
processed += 1
|
|
173
|
+
console.error(`[sweep] Failed on "${slug}":`, (error as Error).message)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log(`[sweep] Deep scan done. ${processed} course(s) touched.`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
main()
|
|
181
|
+
.then(() => process.exit(0))
|
|
182
|
+
.catch(error => {
|
|
183
|
+
console.error("[sweep] Fatal error:", error)
|
|
184
|
+
process.exit(1)
|
|
185
|
+
})
|