@learnpack/learnpack 5.0.352 → 5.0.354
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 +277 -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/awsCredentials.d.ts +20 -0
- package/lib/utils/awsCredentials.js +43 -0
- 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/backfillEvents.d.ts +60 -0
- package/lib/utils/descriptions/backfillEvents.js +107 -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 +66 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +176 -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 +245 -0
- package/lib/utils/descriptions/resumePublication.d.ts +36 -0
- package/lib/utils/descriptions/resumePublication.js +128 -0
- package/lib/utils/descriptions/s3Storage.d.ts +30 -0
- package/lib/utils/descriptions/s3Storage.js +141 -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 +22 -0
- package/lib/utils/packageManifest.js +57 -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/repair/legacyPackageRepair.d.ts +131 -0
- package/lib/utils/repair/legacyPackageRepair.js +492 -0
- package/lib/utils/repair/repairStorage.d.ts +68 -0
- package/lib/utils/repair/repairStorage.js +89 -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 +5 -8
- package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
- package/lib/utils/s3/packageSourcesAudit.js +184 -0
- package/lib/utils/syllabusSync.d.ts +71 -0
- package/lib/utils/syllabusSync.js +273 -0
- package/package.json +3 -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/README.md +244 -0
- package/src/scripts/descriptionsGcsBackfill.ts +193 -0
- package/src/scripts/descriptionsS3Backfill.ts +376 -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/awsCredentials.ts +57 -0
- package/src/utils/creatorUtilities.ts +2 -1
- package/src/utils/descriptionHash.ts +196 -0
- package/src/utils/descriptions/backfillEvents.ts +152 -0
- package/src/utils/descriptions/gcsStorage.ts +67 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +311 -0
- package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
- package/src/utils/descriptions/publishStage.ts +394 -0
- package/src/utils/descriptions/resumePublication.ts +217 -0
- package/src/utils/descriptions/s3Storage.ts +214 -0
- package/src/utils/descriptions/workList.ts +283 -0
- package/src/utils/gcsBucketName.ts +19 -0
- package/src/utils/packageManifest.ts +62 -5
- package/src/utils/publishEvents.ts +181 -0
- package/src/utils/publishJournal.ts +383 -0
- package/src/utils/repair/legacyPackageRepair.ts +731 -0
- package/src/utils/repair/repairStorage.ts +168 -0
- package/src/utils/rigoActions.ts +130 -0
- package/src/utils/s3/packageManifestBackfill.ts +771 -776
- package/src/utils/s3/packageSourcesAudit.ts +311 -0
- package/src/utils/syllabusSync.ts +390 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Syllabus } from "../../models/creator";
|
|
2
|
+
import { CourseDescriptionsStorage } from "./generateCourseDescriptions";
|
|
3
|
+
import { CourseExercise, CourseReadmes } from "./workList";
|
|
4
|
+
/**
|
|
5
|
+
* Copy descriptions from the published syllabus back into the draft one.
|
|
6
|
+
*
|
|
7
|
+
* Descriptions are generated against the published package because it is an
|
|
8
|
+
* immutable snapshot. The draft in GCS, meanwhile, keeps moving: between the
|
|
9
|
+
* publication and the moment the descriptions land, steps may have been added,
|
|
10
|
+
* removed, renamed, reordered or edited.
|
|
11
|
+
*
|
|
12
|
+
* The guard against that drift is the content hash, not a timestamp or a
|
|
13
|
+
* position: a description is copied only when the draft README hashes to the
|
|
14
|
+
* very content the description was generated from. Anything that moved simply
|
|
15
|
+
* misses and waits for the next publication — a stale description is never
|
|
16
|
+
* written onto content it does not describe.
|
|
17
|
+
*
|
|
18
|
+
* Without this mirror the draft would never hold a description, and every
|
|
19
|
+
* publication would regenerate the whole course from scratch: the freshness
|
|
20
|
+
* check reads the fingerprints stored in the syllabus that travels inside the
|
|
21
|
+
* zip.
|
|
22
|
+
*/
|
|
23
|
+
export type MirrorResult = {
|
|
24
|
+
/** (step, language) pairs copied into the draft. */
|
|
25
|
+
copied: number;
|
|
26
|
+
/** Pairs with no reusable description: content moved on, or none published. */
|
|
27
|
+
missed: number;
|
|
28
|
+
/** Pairs the draft already had up to date. */
|
|
29
|
+
fresh: number;
|
|
30
|
+
};
|
|
31
|
+
export type MirrorOptions = {
|
|
32
|
+
promptVersion?: number;
|
|
33
|
+
dryRun?: boolean;
|
|
34
|
+
concurrency?: number;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Pure core: writes into `targetSyllabus` in place. Callers own the I/O, which
|
|
38
|
+
* keeps the drift rules testable without a bucket.
|
|
39
|
+
*/
|
|
40
|
+
export declare function mirrorDescriptionsIntoSyllabus(input: {
|
|
41
|
+
sourceSyllabus: Syllabus | null;
|
|
42
|
+
targetSyllabus: Syllabus;
|
|
43
|
+
exercises: CourseExercise[];
|
|
44
|
+
/** READMEs of the TARGET (draft), which is what must match the hash. */
|
|
45
|
+
readmes: CourseReadmes;
|
|
46
|
+
promptVersion?: number;
|
|
47
|
+
}): MirrorResult;
|
|
48
|
+
/** Read the draft, mirror into it, and save it when something changed. */
|
|
49
|
+
export declare function mirrorDescriptionsToDraft(storage: CourseDescriptionsStorage, courseSlug: string, sourceSyllabus: Syllabus | null, options?: MirrorOptions): Promise<MirrorResult>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mirrorDescriptionsIntoSyllabus = mirrorDescriptionsIntoSyllabus;
|
|
4
|
+
exports.mirrorDescriptionsToDraft = mirrorDescriptionsToDraft;
|
|
5
|
+
const descriptionHash_1 = require("../descriptionHash");
|
|
6
|
+
const packageManifest_1 = require("../packageManifest");
|
|
7
|
+
const generateCourseDescriptions_1 = require("./generateCourseDescriptions");
|
|
8
|
+
const workList_1 = require("./workList");
|
|
9
|
+
function emptyMirrorResult() {
|
|
10
|
+
return { copied: 0, missed: 0, fresh: 0 };
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Pure core: writes into `targetSyllabus` in place. Callers own the I/O, which
|
|
14
|
+
* keeps the drift rules testable without a bucket.
|
|
15
|
+
*/
|
|
16
|
+
function mirrorDescriptionsIntoSyllabus(input) {
|
|
17
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
18
|
+
const result = emptyMirrorResult();
|
|
19
|
+
if (!input.sourceSyllabus) {
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
const promptVersion = (_a = input.promptVersion) !== null && _a !== void 0 ? _a : packageManifest_1.DESCRIPTION_PROMPT_VERSION;
|
|
23
|
+
for (const exercise of input.exercises) {
|
|
24
|
+
const targetLesson = (0, workList_1.findSyllabusLesson)(input.targetSyllabus, exercise.slug);
|
|
25
|
+
if (!targetLesson) {
|
|
26
|
+
// The step is not in the draft (removed, or renamed after publishing).
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const sourceLesson = (0, workList_1.findSyllabusLesson)(input.sourceSyllabus, exercise.slug);
|
|
30
|
+
const exerciseReadmes = input.readmes[exercise.slug] || {};
|
|
31
|
+
for (const lang of Object.keys(exercise.translations || {})) {
|
|
32
|
+
const content = exerciseReadmes[lang];
|
|
33
|
+
if (typeof content !== "string") {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const fingerprint = (0, descriptionHash_1.fingerprintReadme)(content);
|
|
37
|
+
const targetSlot = (_b = targetLesson.translations) === null || _b === void 0 ? void 0 : _b[lang];
|
|
38
|
+
if ((targetSlot === null || targetSlot === void 0 ? void 0 : targetSlot.descriptionSource) === "human") {
|
|
39
|
+
result.fresh += 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if ((targetSlot === null || targetSlot === void 0 ? void 0 : targetSlot.sourceContentHash) === fingerprint.sha256 &&
|
|
43
|
+
((_c = targetSlot === null || targetSlot === void 0 ? void 0 : targetSlot.descriptionPromptVersion) !== null && _c !== void 0 ? _c : 0) >= promptVersion) {
|
|
44
|
+
result.fresh += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const sourceSlot = (_d = sourceLesson === null || sourceLesson === void 0 ? void 0 : sourceLesson.translations) === null || _d === void 0 ? void 0 : _d[lang];
|
|
48
|
+
// The hash guard: only reuse a description generated from exactly this
|
|
49
|
+
// content. Note a published `description: null` is reusable too — it is a
|
|
50
|
+
// real answer ("not enough content to describe"), not a missing one.
|
|
51
|
+
const reusable = sourceSlot !== undefined &&
|
|
52
|
+
sourceSlot.sourceContentHash === fingerprint.sha256 &&
|
|
53
|
+
((_e = sourceSlot.descriptionPromptVersion) !== null && _e !== void 0 ? _e : 0) >= promptVersion &&
|
|
54
|
+
sourceSlot.descriptionStatus !== "error";
|
|
55
|
+
if (!reusable) {
|
|
56
|
+
result.missed += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!targetLesson.translations) {
|
|
60
|
+
targetLesson.translations = {};
|
|
61
|
+
}
|
|
62
|
+
if (!targetLesson.translations[lang]) {
|
|
63
|
+
targetLesson.translations[lang] = {
|
|
64
|
+
completionId: 0,
|
|
65
|
+
startedAt: Date.now(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const destination = targetLesson.translations[lang];
|
|
69
|
+
destination.description = (_f = sourceSlot.description) !== null && _f !== void 0 ? _f : null;
|
|
70
|
+
destination.descriptionStatus = sourceSlot.description ?
|
|
71
|
+
"generated" :
|
|
72
|
+
"none";
|
|
73
|
+
destination.descriptionSource = "auto";
|
|
74
|
+
destination.descriptionPromptVersion =
|
|
75
|
+
(_g = sourceSlot.descriptionPromptVersion) !== null && _g !== void 0 ? _g : promptVersion;
|
|
76
|
+
destination.sourceContentHash = fingerprint.sha256;
|
|
77
|
+
destination.sourceSimHash = fingerprint.simhash;
|
|
78
|
+
result.copied += 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
/** Read the draft, mirror into it, and save it when something changed. */
|
|
84
|
+
async function mirrorDescriptionsToDraft(storage, courseSlug, sourceSyllabus, options = {}) {
|
|
85
|
+
var _a;
|
|
86
|
+
if (!sourceSyllabus) {
|
|
87
|
+
return emptyMirrorResult();
|
|
88
|
+
}
|
|
89
|
+
const targetSyllabus = await storage.readSyllabus(courseSlug);
|
|
90
|
+
if (!targetSyllabus) {
|
|
91
|
+
return emptyMirrorResult();
|
|
92
|
+
}
|
|
93
|
+
const exercises = await storage.listExercises(courseSlug);
|
|
94
|
+
if (exercises.length === 0) {
|
|
95
|
+
return emptyMirrorResult();
|
|
96
|
+
}
|
|
97
|
+
const readmes = await (0, generateCourseDescriptions_1.readCourseReadmes)(storage, courseSlug, exercises, (_a = options.concurrency) !== null && _a !== void 0 ? _a : generateCourseDescriptions_1.DEFAULT_DESCRIPTIONS_CONCURRENCY);
|
|
98
|
+
const result = mirrorDescriptionsIntoSyllabus({
|
|
99
|
+
sourceSyllabus,
|
|
100
|
+
targetSyllabus,
|
|
101
|
+
exercises,
|
|
102
|
+
readmes,
|
|
103
|
+
promptVersion: options.promptVersion,
|
|
104
|
+
});
|
|
105
|
+
if (result.copied > 0 && !options.dryRun) {
|
|
106
|
+
await storage.writeSyllabus(courseSlug, targetSyllabus);
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Bucket } from "@google-cloud/storage";
|
|
2
|
+
import { JournalStorage, PublishJournal } from "../publishJournal";
|
|
3
|
+
import { CourseDescriptionsStorage, GenerateCourseDescriptionsOptions, GenerateCourseDescriptionsResult } from "./generateCourseDescriptions";
|
|
4
|
+
import { MirrorResult } from "./mirrorDescriptions";
|
|
5
|
+
import { PublishEventContext, sendPublishEvent } from "../publishEvents";
|
|
6
|
+
import { CourseExercise } from "./workList";
|
|
7
|
+
/**
|
|
8
|
+
* Stage B of a publication: generate the descriptions the new content needs.
|
|
9
|
+
*
|
|
10
|
+
* It runs in the background, after the HTTP response, because a single
|
|
11
|
+
* completion takes ~15s and a course may need several — far past any request
|
|
12
|
+
* limit. The publication itself is already finished and durable by then; this
|
|
13
|
+
* stage only enriches the published package.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Whether the freshly built package needs any description, decided from the
|
|
17
|
+
* build directory that is still on disk after zipping.
|
|
18
|
+
*
|
|
19
|
+
* Reading from disk (instead of re-reading GCS or S3) is free and, at this
|
|
20
|
+
* exact moment, all three hold the same content: the answer is what the publish
|
|
21
|
+
* response promises to breathecode, so it must be cheap enough to compute
|
|
22
|
+
* before responding.
|
|
23
|
+
*/
|
|
24
|
+
/** Reads a path relative to the build root; null when it does not exist. */
|
|
25
|
+
export type BuildFileReader = (relativePath: string) => string | null;
|
|
26
|
+
export declare function createBuildFileReader(buildRoot: string): BuildFileReader;
|
|
27
|
+
export declare function countPendingDescriptions(buildRoot: string, exercises: CourseExercise[], readFile?: BuildFileReader): number;
|
|
28
|
+
export type PublishDescriptionsStageResult = {
|
|
29
|
+
generation: GenerateCourseDescriptionsResult | null;
|
|
30
|
+
mirror: MirrorResult | null;
|
|
31
|
+
/** False when generation failed outright; drives the event status. */
|
|
32
|
+
succeeded: boolean;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Generate against the published package, then mirror what still matches back
|
|
36
|
+
* into the draft. Stages are recorded as they go so an interrupted run can be
|
|
37
|
+
* resumed by the sweep.
|
|
38
|
+
*/
|
|
39
|
+
export declare function runPublishDescriptionsStage(params: {
|
|
40
|
+
courseSlug: string;
|
|
41
|
+
rigobotToken: string;
|
|
42
|
+
gcsBucket?: Bucket;
|
|
43
|
+
journal: PublishJournal;
|
|
44
|
+
journalStorage: JournalStorage;
|
|
45
|
+
/** Injection points for tests; production resolves them from env/bucket. */
|
|
46
|
+
publishedStorage?: CourseDescriptionsStorage;
|
|
47
|
+
draftStorage?: CourseDescriptionsStorage;
|
|
48
|
+
generate?: GenerateCourseDescriptionsOptions["generate"];
|
|
49
|
+
}): Promise<PublishDescriptionsStageResult>;
|
|
50
|
+
/**
|
|
51
|
+
* Entry point for the detached run after publishing. Never throws: it is not
|
|
52
|
+
* awaited by anyone, so an escaping rejection would only surface as an
|
|
53
|
+
* unhandled promise.
|
|
54
|
+
*/
|
|
55
|
+
export declare function runDescriptionsStageAfterPublish(params: {
|
|
56
|
+
courseSlug: string;
|
|
57
|
+
gcsBucket?: Bucket;
|
|
58
|
+
journal: PublishJournal;
|
|
59
|
+
journalStorage: JournalStorage;
|
|
60
|
+
hasWork: boolean;
|
|
61
|
+
/** Present when the caller announced `descriptions: "queued"`. */
|
|
62
|
+
eventContext?: PublishEventContext;
|
|
63
|
+
breathecodeToken?: string;
|
|
64
|
+
publishedStorage?: CourseDescriptionsStorage;
|
|
65
|
+
draftStorage?: CourseDescriptionsStorage;
|
|
66
|
+
generate?: GenerateCourseDescriptionsOptions["generate"];
|
|
67
|
+
/** Injection point for tests. */
|
|
68
|
+
emit?: typeof sendPublishEvent;
|
|
69
|
+
}): Promise<PublishDescriptionsStageResult>;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createBuildFileReader = createBuildFileReader;
|
|
4
|
+
exports.countPendingDescriptions = countPendingDescriptions;
|
|
5
|
+
exports.runPublishDescriptionsStage = runPublishDescriptionsStage;
|
|
6
|
+
exports.runDescriptionsStageAfterPublish = runDescriptionsStageAfterPublish;
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const publishJournal_1 = require("../publishJournal");
|
|
10
|
+
const generateCourseDescriptions_1 = require("./generateCourseDescriptions");
|
|
11
|
+
const gcsStorage_1 = require("./gcsStorage");
|
|
12
|
+
const mirrorDescriptions_1 = require("./mirrorDescriptions");
|
|
13
|
+
const s3Storage_1 = require("./s3Storage");
|
|
14
|
+
const publishEvents_1 = require("../publishEvents");
|
|
15
|
+
const workList_1 = require("./workList");
|
|
16
|
+
function createBuildFileReader(buildRoot) {
|
|
17
|
+
return relativePath => {
|
|
18
|
+
try {
|
|
19
|
+
return fs.readFileSync(path.join(buildRoot, ...relativePath.split("/")), "utf8");
|
|
20
|
+
}
|
|
21
|
+
catch (_a) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function countPendingDescriptions(buildRoot, exercises, readFile = createBuildFileReader(buildRoot)) {
|
|
27
|
+
const rawSyllabus = readFile(".learn/initialSyllabus.json");
|
|
28
|
+
if (!rawSyllabus) {
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
let syllabus;
|
|
32
|
+
try {
|
|
33
|
+
syllabus = JSON.parse(rawSyllabus);
|
|
34
|
+
}
|
|
35
|
+
catch (_a) {
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
const readmes = {};
|
|
39
|
+
for (const exercise of exercises) {
|
|
40
|
+
for (const [lang, fileName] of Object.entries(exercise.translations || {})) {
|
|
41
|
+
const content = readFile(`exercises/${exercise.slug}/${fileName}`);
|
|
42
|
+
if (content === null) {
|
|
43
|
+
// Missing translation file: nothing to fingerprint, nothing to ask for.
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!readmes[exercise.slug]) {
|
|
47
|
+
readmes[exercise.slug] = {};
|
|
48
|
+
}
|
|
49
|
+
readmes[exercise.slug][lang] = content;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return (0, workList_1.countWorkItems)((0, workList_1.buildDescriptionWorkList)({ syllabus, exercises, readmes }));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Generate against the published package, then mirror what still matches back
|
|
56
|
+
* into the draft. Stages are recorded as they go so an interrupted run can be
|
|
57
|
+
* resumed by the sweep.
|
|
58
|
+
*/
|
|
59
|
+
async function runPublishDescriptionsStage(params) {
|
|
60
|
+
var _a, _b;
|
|
61
|
+
const { courseSlug, journal, journalStorage } = params;
|
|
62
|
+
const publishedStorage = (_a = params.publishedStorage) !== null && _a !== void 0 ? _a : (0, s3Storage_1.createS3DescriptionsStorageFromEnv)();
|
|
63
|
+
await (0, publishJournal_1.markStage)(journalStorage, journal, "descriptions", "running");
|
|
64
|
+
let generation = null;
|
|
65
|
+
try {
|
|
66
|
+
generation = await (0, generateCourseDescriptions_1.generateCourseDescriptions)(publishedStorage, courseSlug, {
|
|
67
|
+
token: params.rigobotToken,
|
|
68
|
+
reconcile: true,
|
|
69
|
+
generate: params.generate,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
await (0, publishJournal_1.markStage)(journalStorage, journal, "descriptions", "failed", {
|
|
74
|
+
error: error.message,
|
|
75
|
+
});
|
|
76
|
+
return { generation: null, mirror: null, succeeded: false };
|
|
77
|
+
}
|
|
78
|
+
const succeeded = generation.status !== "failed";
|
|
79
|
+
await (0, publishJournal_1.markStage)(journalStorage, journal, "descriptions", succeeded ? "done" : "failed", {
|
|
80
|
+
error: succeeded ? undefined : generation.errors.join("; "),
|
|
81
|
+
meta: {
|
|
82
|
+
steps: generation.steps,
|
|
83
|
+
generated: generation.generated,
|
|
84
|
+
failed: generation.failed,
|
|
85
|
+
missing: generation.missing,
|
|
86
|
+
durationsSeconds: generation.durationsSeconds,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
if (!succeeded) {
|
|
90
|
+
return { generation, mirror: null, succeeded: false };
|
|
91
|
+
}
|
|
92
|
+
// Mirror back so the draft keeps the fingerprints: without it the next
|
|
93
|
+
// publication would regenerate the whole course.
|
|
94
|
+
await (0, publishJournal_1.markStage)(journalStorage, journal, "gcsMirror", "running");
|
|
95
|
+
try {
|
|
96
|
+
const draftStorage = (_b = params.draftStorage) !== null && _b !== void 0 ? _b : (0, gcsStorage_1.createGcsDescriptionsStorage)(params.gcsBucket);
|
|
97
|
+
const publishedSyllabus = await publishedStorage.readSyllabus(courseSlug);
|
|
98
|
+
const mirror = await (0, mirrorDescriptions_1.mirrorDescriptionsToDraft)(draftStorage, courseSlug, publishedSyllabus);
|
|
99
|
+
await (0, publishJournal_1.markStage)(journalStorage, journal, "gcsMirror", "done", {
|
|
100
|
+
meta: mirror,
|
|
101
|
+
});
|
|
102
|
+
return { generation, mirror, succeeded: true };
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
// The published package is already correct; only the draft lags behind.
|
|
106
|
+
await (0, publishJournal_1.markStage)(journalStorage, journal, "gcsMirror", "failed", {
|
|
107
|
+
error: error.message,
|
|
108
|
+
});
|
|
109
|
+
return { generation, mirror: null, succeeded: true };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Entry point for the detached run after publishing. Never throws: it is not
|
|
114
|
+
* awaited by anyone, so an escaping rejection would only surface as an
|
|
115
|
+
* unhandled promise.
|
|
116
|
+
*/
|
|
117
|
+
async function runDescriptionsStageAfterPublish(params) {
|
|
118
|
+
var _a, _b, _c, _d, _e, _f;
|
|
119
|
+
const { courseSlug, journal, journalStorage } = params;
|
|
120
|
+
let outcome = {
|
|
121
|
+
generation: null,
|
|
122
|
+
mirror: null,
|
|
123
|
+
succeeded: true,
|
|
124
|
+
};
|
|
125
|
+
// Resolved on demand: the common "nothing to describe" path must not build
|
|
126
|
+
// AWS clients it will never use.
|
|
127
|
+
let resolved = params.publishedStorage;
|
|
128
|
+
const publishedStorage = () => {
|
|
129
|
+
if (!resolved) {
|
|
130
|
+
resolved = (0, s3Storage_1.createS3DescriptionsStorageFromEnv)();
|
|
131
|
+
}
|
|
132
|
+
return resolved;
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
if (!params.hasWork) {
|
|
136
|
+
// Nothing changed enough to describe; both stages are settled, and no
|
|
137
|
+
// second event was promised, so none is sent.
|
|
138
|
+
await (0, publishJournal_1.markStage)(journalStorage, journal, "descriptions", "done", {
|
|
139
|
+
meta: { reason: "up_to_date" },
|
|
140
|
+
});
|
|
141
|
+
await (0, publishJournal_1.markStage)(journalStorage, journal, "gcsMirror", "done", {
|
|
142
|
+
meta: { reason: "up_to_date" },
|
|
143
|
+
});
|
|
144
|
+
await (0, publishJournal_1.markStage)(journalStorage, journal, "manifestEvent", "done", {
|
|
145
|
+
meta: { reason: "not_promised" },
|
|
146
|
+
});
|
|
147
|
+
return outcome;
|
|
148
|
+
}
|
|
149
|
+
// Descriptions are platform enrichment, not something the publisher is
|
|
150
|
+
// billed for, so they always run under the service account.
|
|
151
|
+
const token = process.env.RIGOBOT_SYSTEM_TOKEN;
|
|
152
|
+
if (!token) {
|
|
153
|
+
await (0, publishJournal_1.markStage)(journalStorage, journal, "descriptions", "failed", {
|
|
154
|
+
error: "RIGOBOT_SYSTEM_TOKEN is not configured",
|
|
155
|
+
});
|
|
156
|
+
console.error(`[descriptions] Skipping stage for "${courseSlug}": RIGOBOT_SYSTEM_TOKEN is not configured`);
|
|
157
|
+
outcome = { generation: null, mirror: null, succeeded: false };
|
|
158
|
+
return outcome;
|
|
159
|
+
}
|
|
160
|
+
outcome = await runPublishDescriptionsStage({
|
|
161
|
+
courseSlug,
|
|
162
|
+
rigobotToken: token,
|
|
163
|
+
gcsBucket: params.gcsBucket,
|
|
164
|
+
journal,
|
|
165
|
+
journalStorage,
|
|
166
|
+
publishedStorage: publishedStorage(),
|
|
167
|
+
draftStorage: params.draftStorage,
|
|
168
|
+
generate: params.generate,
|
|
169
|
+
});
|
|
170
|
+
if (outcome.succeeded) {
|
|
171
|
+
console.log(`[descriptions] "${courseSlug}": ${(_b = (_a = outcome.generation) === null || _a === void 0 ? void 0 : _a.generated) !== null && _b !== void 0 ? _b : 0} description(s), ${(_d = (_c = outcome.mirror) === null || _c === void 0 ? void 0 : _c.copied) !== null && _d !== void 0 ? _d : 0} mirrored`);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
console.error(`[descriptions] "${courseSlug}": stage failed${((_e = outcome.generation) === null || _e === void 0 ? void 0 : _e.errors.length) ?
|
|
175
|
+
` (${outcome.generation.errors.join("; ")})` :
|
|
176
|
+
""}`);
|
|
177
|
+
}
|
|
178
|
+
return outcome;
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
console.error(`[descriptions] Stage failed for "${courseSlug}":`, error.message);
|
|
182
|
+
outcome = { generation: null, mirror: null, succeeded: false };
|
|
183
|
+
return outcome;
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
// The contract: a publication that announced `descriptions: "queued"` is
|
|
187
|
+
// always followed by exactly one manifest event, success or failure.
|
|
188
|
+
// Emitting from `finally` is what makes "exactly one, on every exit path"
|
|
189
|
+
// true by construction rather than by remembering to call it.
|
|
190
|
+
if (params.hasWork) {
|
|
191
|
+
await emitManifestUpdated({
|
|
192
|
+
courseSlug,
|
|
193
|
+
journal,
|
|
194
|
+
journalStorage,
|
|
195
|
+
outcome,
|
|
196
|
+
eventContext: params.eventContext,
|
|
197
|
+
breathecodeToken: params.breathecodeToken,
|
|
198
|
+
publishedStorage: publishedStorage(),
|
|
199
|
+
emit: (_f = params.emit) !== null && _f !== void 0 ? _f : publishEvents_1.sendPublishEvent,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
await (0, publishJournal_1.finalizeJournal)(journalStorage, journal);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function emitManifestUpdated(params) {
|
|
206
|
+
var _a, _b, _c;
|
|
207
|
+
const { journal, journalStorage } = params;
|
|
208
|
+
if (!params.eventContext || !params.breathecodeToken) {
|
|
209
|
+
// Nothing was announced to breathecode, so nothing is owed.
|
|
210
|
+
await (0, publishJournal_1.markStage)(journalStorage, journal, "manifestEvent", "done", {
|
|
211
|
+
meta: { reason: "no_event_context" },
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Enriching the payload must never prevent the delivery. The manifest is
|
|
216
|
+
// optional in the contract (`manifest: null` is valid); the event is not:
|
|
217
|
+
// breathecode was promised one and is waiting for it. Reading the manifest
|
|
218
|
+
// from the published bucket can fail for reasons that have nothing to do with
|
|
219
|
+
// the event — a bad AWS credential, say — and an event with less context still
|
|
220
|
+
// beats an event that never arrives.
|
|
221
|
+
let updatedManifest = params.eventContext.manifest;
|
|
222
|
+
try {
|
|
223
|
+
updatedManifest =
|
|
224
|
+
(_c = (await ((_b = (_a = params.publishedStorage).readManifest) === null || _b === void 0 ? void 0 : _b.call(_a, params.courseSlug)))) !== null && _c !== void 0 ? _c : params.eventContext.manifest;
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
console.error(`[descriptions] Could not read the updated manifest for "${params.courseSlug}", reporting without it:`, error.message);
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const generation = params.outcome.generation;
|
|
231
|
+
const delivered = await params.emit((0, publishEvents_1.buildPackageManifestUpdatedEvent)(Object.assign(Object.assign({}, params.eventContext), { manifest: updatedManifest }), params.outcome.succeeded ? "success" : "failed", generation ?
|
|
232
|
+
{
|
|
233
|
+
generated: generation.generated,
|
|
234
|
+
failed: generation.failed,
|
|
235
|
+
missing: generation.missing,
|
|
236
|
+
} :
|
|
237
|
+
undefined), params.breathecodeToken);
|
|
238
|
+
await (0, publishJournal_1.markStage)(journalStorage, journal, "manifestEvent", delivered ? "done" : "failed", { error: delivered ? undefined : "delivery failed" });
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
await (0, publishJournal_1.markStage)(journalStorage, journal, "manifestEvent", "failed", {
|
|
242
|
+
error: error.message,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { JournalStorage, PublishJournal } from "../publishJournal";
|
|
2
|
+
import { fetchPackageInfo, sendPublishEvent } from "../publishEvents";
|
|
3
|
+
import { CourseDescriptionsStorage, GenerateCourseDescriptionsOptions } from "./generateCourseDescriptions";
|
|
4
|
+
/**
|
|
5
|
+
* Recovery of a publication whose background stage never finished.
|
|
6
|
+
*
|
|
7
|
+
* The stage runs inside the web process, so a dyno restart mid-flight leaves a
|
|
8
|
+
* package published but not described, and — worse — breathecode waiting for a
|
|
9
|
+
* manifest event that was promised and will never arrive. Content-wise the
|
|
10
|
+
* missing descriptions would eventually be picked up by the next publication,
|
|
11
|
+
* but the promised event has no such second chance: emitting it is the reason
|
|
12
|
+
* this exists.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MAX_SWEEP_ATTEMPTS = 3;
|
|
15
|
+
/** Only resume publications nobody is working on any more. */
|
|
16
|
+
export declare const DEFAULT_STALE_AFTER_MS: number;
|
|
17
|
+
export type ResumeOutcome = "skipped-recent" | "skipped-abandoned" | "abandoned" | "resumed" | "nothing-to-do";
|
|
18
|
+
export type ResumeDeps = {
|
|
19
|
+
journalStorage: JournalStorage;
|
|
20
|
+
publishedStorage: CourseDescriptionsStorage;
|
|
21
|
+
draftStorage: CourseDescriptionsStorage;
|
|
22
|
+
rigobotToken: string;
|
|
23
|
+
breathecodeToken?: string;
|
|
24
|
+
staleAfterMs?: number;
|
|
25
|
+
now?: number;
|
|
26
|
+
/** Injection points for tests. */
|
|
27
|
+
emit?: typeof sendPublishEvent;
|
|
28
|
+
readPackageInfo?: typeof fetchPackageInfo;
|
|
29
|
+
generate?: GenerateCourseDescriptionsOptions["generate"];
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Whether breathecode is still owed a manifest event: one was promised (the
|
|
33
|
+
* publish event announced `queued`) and it never went out.
|
|
34
|
+
*/
|
|
35
|
+
export declare function owesManifestEvent(journal: PublishJournal): boolean;
|
|
36
|
+
export declare function resumePublication(journal: PublishJournal, deps: ResumeDeps): Promise<ResumeOutcome>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_STALE_AFTER_MS = exports.MAX_SWEEP_ATTEMPTS = void 0;
|
|
4
|
+
exports.owesManifestEvent = owesManifestEvent;
|
|
5
|
+
exports.resumePublication = resumePublication;
|
|
6
|
+
const publishJournal_1 = require("../publishJournal");
|
|
7
|
+
const publishEvents_1 = require("../publishEvents");
|
|
8
|
+
const publishStage_1 = require("./publishStage");
|
|
9
|
+
/**
|
|
10
|
+
* Recovery of a publication whose background stage never finished.
|
|
11
|
+
*
|
|
12
|
+
* The stage runs inside the web process, so a dyno restart mid-flight leaves a
|
|
13
|
+
* package published but not described, and — worse — breathecode waiting for a
|
|
14
|
+
* manifest event that was promised and will never arrive. Content-wise the
|
|
15
|
+
* missing descriptions would eventually be picked up by the next publication,
|
|
16
|
+
* but the promised event has no such second chance: emitting it is the reason
|
|
17
|
+
* this exists.
|
|
18
|
+
*/
|
|
19
|
+
exports.MAX_SWEEP_ATTEMPTS = 3;
|
|
20
|
+
/** Only resume publications nobody is working on any more. */
|
|
21
|
+
exports.DEFAULT_STALE_AFTER_MS = 15 * 60 * 1000;
|
|
22
|
+
function isDone(journal, stage) {
|
|
23
|
+
return journal.stages[stage].status === "done";
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Whether breathecode is still owed a manifest event: one was promised (the
|
|
27
|
+
* publish event announced `queued`) and it never went out.
|
|
28
|
+
*/
|
|
29
|
+
function owesManifestEvent(journal) {
|
|
30
|
+
var _a;
|
|
31
|
+
const promised = ((_a = journal.stages.publishedEvent.meta) === null || _a === void 0 ? void 0 : _a.descriptions) === "queued";
|
|
32
|
+
return promised && !isDone(journal, "manifestEvent");
|
|
33
|
+
}
|
|
34
|
+
async function resumePublication(journal, deps) {
|
|
35
|
+
var _a, _b;
|
|
36
|
+
const { journalStorage } = deps;
|
|
37
|
+
const now = (_a = deps.now) !== null && _a !== void 0 ? _a : Date.now();
|
|
38
|
+
if (journal.abandoned) {
|
|
39
|
+
return "skipped-abandoned";
|
|
40
|
+
}
|
|
41
|
+
// A publication still in flight would race with its own stage.
|
|
42
|
+
if (!(0, publishJournal_1.isJournalStale)(journal, (_b = deps.staleAfterMs) !== null && _b !== void 0 ? _b : exports.DEFAULT_STALE_AFTER_MS, now)) {
|
|
43
|
+
return "skipped-recent";
|
|
44
|
+
}
|
|
45
|
+
const needsDescriptions = !isDone(journal, "descriptions") || !isDone(journal, "gcsMirror");
|
|
46
|
+
const needsEvent = owesManifestEvent(journal);
|
|
47
|
+
if (!needsDescriptions && !needsEvent) {
|
|
48
|
+
// Nothing left that the sweep can do; stage A failures are not its job.
|
|
49
|
+
await (0, publishJournal_1.finalizeJournal)(journalStorage, journal);
|
|
50
|
+
return "nothing-to-do";
|
|
51
|
+
}
|
|
52
|
+
if (journal.attempts >= exports.MAX_SWEEP_ATTEMPTS) {
|
|
53
|
+
await (0, publishJournal_1.abandonJournal)(journalStorage, journal);
|
|
54
|
+
console.error(`[sweep] Giving up on "${journal.courseSlug}" (${journal.publishId}) after ${journal.attempts} attempts`);
|
|
55
|
+
return "abandoned";
|
|
56
|
+
}
|
|
57
|
+
await (0, publishJournal_1.incrementAttempts)(journalStorage, journal);
|
|
58
|
+
let succeeded = true;
|
|
59
|
+
let stats;
|
|
60
|
+
if (needsDescriptions) {
|
|
61
|
+
const outcome = await (0, publishStage_1.runPublishDescriptionsStage)({
|
|
62
|
+
courseSlug: journal.courseSlug,
|
|
63
|
+
rigobotToken: deps.rigobotToken,
|
|
64
|
+
journal,
|
|
65
|
+
journalStorage,
|
|
66
|
+
publishedStorage: deps.publishedStorage,
|
|
67
|
+
draftStorage: deps.draftStorage,
|
|
68
|
+
generate: deps.generate,
|
|
69
|
+
});
|
|
70
|
+
succeeded = outcome.succeeded;
|
|
71
|
+
if (outcome.generation) {
|
|
72
|
+
stats = {
|
|
73
|
+
generated: outcome.generation.generated,
|
|
74
|
+
failed: outcome.generation.failed,
|
|
75
|
+
missing: outcome.generation.missing,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (needsEvent) {
|
|
80
|
+
await emitPendingManifestEvent(journal, deps, succeeded, stats);
|
|
81
|
+
}
|
|
82
|
+
await (0, publishJournal_1.finalizeJournal)(journalStorage, journal);
|
|
83
|
+
return "resumed";
|
|
84
|
+
}
|
|
85
|
+
async function emitPendingManifestEvent(journal, deps, succeeded, stats) {
|
|
86
|
+
var _a, _b;
|
|
87
|
+
const emit = (_a = deps.emit) !== null && _a !== void 0 ? _a : publishEvents_1.sendPublishEvent;
|
|
88
|
+
const readPackageInfo = (_b = deps.readPackageInfo) !== null && _b !== void 0 ? _b : publishEvents_1.fetchPackageInfo;
|
|
89
|
+
if (!deps.breathecodeToken) {
|
|
90
|
+
await (0, publishJournal_1.markStage)(deps.journalStorage, journal, "manifestEvent", "failed", {
|
|
91
|
+
error: "BREATHECODE_SYSTEM_TOKEN is not configured",
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// Enriching the payload must never prevent the delivery: this event is the
|
|
96
|
+
// one breathecode has been waiting for since the publication stalled, and
|
|
97
|
+
// both the manifest and the package info are optional in the contract.
|
|
98
|
+
let manifest = null;
|
|
99
|
+
let packageInfo = null;
|
|
100
|
+
try {
|
|
101
|
+
if (deps.publishedStorage.readManifest) {
|
|
102
|
+
manifest = await deps.publishedStorage.readManifest(journal.courseSlug);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
console.error(`[sweep] Could not read the manifest of "${journal.courseSlug}", reporting without it:`, error.message);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
packageInfo = await readPackageInfo(journal.courseSlug, deps.rigobotToken);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
console.error(`[sweep] Could not read the package of "${journal.courseSlug}", reporting without it:`, error.message);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const delivered = await emit((0, publishEvents_1.buildPackageManifestUpdatedEvent)({
|
|
116
|
+
publishId: journal.publishId,
|
|
117
|
+
courseSlug: journal.courseSlug,
|
|
118
|
+
packageInfo,
|
|
119
|
+
manifest,
|
|
120
|
+
}, succeeded ? "success" : "failed", stats), deps.breathecodeToken);
|
|
121
|
+
await (0, publishJournal_1.markStage)(deps.journalStorage, journal, "manifestEvent", delivered ? "done" : "failed", { error: delivered ? undefined : "delivery failed" });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
await (0, publishJournal_1.markStage)(deps.journalStorage, journal, "manifestEvent", "failed", {
|
|
125
|
+
error: error.message,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AwsClient } from "../s3/packageManifestBackfill";
|
|
2
|
+
import { SyllabusSyncStorage } from "../syllabusSync";
|
|
3
|
+
import { CourseDescriptionsStorage } from "./generateCourseDescriptions";
|
|
4
|
+
import { CourseExercise } from "./workList";
|
|
5
|
+
/**
|
|
6
|
+
* Published-bucket (S3) adapter for the description flows.
|
|
7
|
+
*
|
|
8
|
+
* The published package is the snapshot descriptions are generated from: it is
|
|
9
|
+
* immutable between publications, unlike the draft in GCS, which keeps moving
|
|
10
|
+
* while the teacher edits.
|
|
11
|
+
*/
|
|
12
|
+
export declare const syllabusKey: (slug: string) => string;
|
|
13
|
+
export declare const manifestKey: (slug: string) => string;
|
|
14
|
+
export declare function fetchExercises(s3: AwsClient, bucket: string, slug: string): Promise<CourseExercise[]>;
|
|
15
|
+
export declare function createS3SyllabusSyncStorage(s3: AwsClient, bucket: string): SyllabusSyncStorage;
|
|
16
|
+
export type S3DescriptionsStorageOptions = {
|
|
17
|
+
/** CloudFront distribution to invalidate after re-projecting the manifest. */
|
|
18
|
+
cloudFront?: {
|
|
19
|
+
client: AwsClient;
|
|
20
|
+
distributionId: string;
|
|
21
|
+
};
|
|
22
|
+
/** Set false in the backfill, where manifests are projected in a later pass. */
|
|
23
|
+
reprojectManifest?: boolean;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Storage for the published bucket, wired from the environment. Used by the
|
|
27
|
+
* post-publish stage and the sweep, which have no CLI flags to read.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createS3DescriptionsStorageFromEnv(): CourseDescriptionsStorage;
|
|
30
|
+
export declare function createS3DescriptionsStorage(s3: AwsClient, bucket: string, options?: S3DescriptionsStorageOptions): CourseDescriptionsStorage;
|