@learnpack/learnpack 2.1.32 → 2.1.33

Sign up to get free protection for your applications and to get access to all the features.
package/README.md CHANGED
@@ -21,7 +21,7 @@ $ npm install -g @learnpack/learnpack
21
21
  $ learnpack COMMAND
22
22
  running command...
23
23
  $ learnpack (-v|--version|version)
24
- @learnpack/learnpack/2.1.32 darwin-arm64 node-v16.20.0
24
+ @learnpack/learnpack/2.1.33 darwin-arm64 node-v16.20.0
25
25
  $ learnpack --help [COMMAND]
26
26
  USAGE
27
27
  $ learnpack COMMAND
@@ -74,7 +74,7 @@ DESCRIPTION
74
74
  12. If there is a file within the exercises folder but not inside of any particular exercise's folder. (Warning)
75
75
  ```
76
76
 
77
- _See code: [src/commands/audit.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.32/src/commands/audit.ts)_
77
+ _See code: [src/commands/audit.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.33/src/commands/audit.ts)_
78
78
 
79
79
  ## `learnpack clean`
80
80
 
@@ -89,7 +89,7 @@ DESCRIPTION
89
89
  Extra documentation goes here
90
90
  ```
91
91
 
92
- _See code: [src/commands/clean.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.32/src/commands/clean.ts)_
92
+ _See code: [src/commands/clean.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.33/src/commands/clean.ts)_
93
93
 
94
94
  ## `learnpack download [PACKAGE]`
95
95
 
@@ -107,7 +107,7 @@ DESCRIPTION
107
107
  Extra documentation goes here
108
108
  ```
109
109
 
110
- _See code: [src/commands/download.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.32/src/commands/download.ts)_
110
+ _See code: [src/commands/download.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.33/src/commands/download.ts)_
111
111
 
112
112
  ## `learnpack help [COMMAND]`
113
113
 
@@ -138,7 +138,7 @@ OPTIONS
138
138
  -h, --grading show CLI help
139
139
  ```
140
140
 
141
- _See code: [src/commands/init.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.32/src/commands/init.ts)_
141
+ _See code: [src/commands/init.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.33/src/commands/init.ts)_
142
142
 
143
143
  ## `learnpack login [PACKAGE]`
144
144
 
@@ -156,7 +156,7 @@ DESCRIPTION
156
156
  Extra documentation goes here
157
157
  ```
158
158
 
159
- _See code: [src/commands/login.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.32/src/commands/login.ts)_
159
+ _See code: [src/commands/login.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.33/src/commands/login.ts)_
160
160
 
161
161
  ## `learnpack logout [PACKAGE]`
162
162
 
@@ -174,7 +174,7 @@ DESCRIPTION
174
174
  Extra documentation goes here
175
175
  ```
176
176
 
177
- _See code: [src/commands/logout.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.32/src/commands/logout.ts)_
177
+ _See code: [src/commands/logout.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.33/src/commands/logout.ts)_
178
178
 
179
179
  ## `learnpack plugins`
180
180
 
@@ -309,7 +309,7 @@ DESCRIPTION
309
309
  Extra documentation goes here
310
310
  ```
311
311
 
312
- _See code: [src/commands/publish.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.32/src/commands/publish.ts)_
312
+ _See code: [src/commands/publish.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.33/src/commands/publish.ts)_
313
313
 
314
314
  ## `learnpack start`
315
315
 
@@ -330,7 +330,7 @@ OPTIONS
330
330
  -w, --watch Watch for file changes
331
331
  ```
332
332
 
333
- _See code: [src/commands/start.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.32/src/commands/start.ts)_
333
+ _See code: [src/commands/start.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.33/src/commands/start.ts)_
334
334
 
335
335
  ## `learnpack test [EXERCISESLUG]`
336
336
 
@@ -344,7 +344,7 @@ ARGUMENTS
344
344
  EXERCISESLUG The name of the exercise to test
345
345
  ```
346
346
 
347
- _See code: [src/commands/test.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.32/src/commands/test.ts)_
347
+ _See code: [src/commands/test.ts](https://github.com/learnpack/learnpack-cli/blob/v2.1.33/src/commands/test.ts)_
348
348
  <!-- commandsstop -->
349
349
 
350
350
  > > > > > > > 0cb3e56d84c197f9d008836bb573eade212b7e57
@@ -6,10 +6,10 @@ const SessionCommand_1 = require("../utils/SessionCommand");
6
6
  const console_1 = require("../utils/console");
7
7
  const socket_1 = require("../managers/socket");
8
8
  const telemetry_1 = require("../managers/telemetry");
9
+ const server_1 = require("../managers/server");
9
10
  const fileQueue_1 = require("../utils/fileQueue");
10
11
  const file_1 = require("../managers/file");
11
12
  const misc_1 = require("../utils/misc");
12
- const server_1 = require("../managers/server");
13
13
  class StartCommand extends SessionCommand_1.default {
14
14
  // 🛑 IMPORTANT
15
15
  // Every command that will use the configManager needs this init method
@@ -18,7 +18,7 @@ class StartCommand extends SessionCommand_1.default {
18
18
  await this.initSession(flags);
19
19
  }
20
20
  async run() {
21
- var _a, _b, _c;
21
+ var _a, _b, _c, _d, _e;
22
22
  // get configuration object
23
23
  const configObject = (_a = this.configManager) === null || _a === void 0 ? void 0 : _a.get();
24
24
  const config = configObject === null || configObject === void 0 ? void 0 : configObject.config;
@@ -42,6 +42,25 @@ class StartCommand extends SessionCommand_1.default {
42
42
  create: true,
43
43
  path: `${config.dirPath}/vscode_queue.json`,
44
44
  });
45
+ if (configObject.exercises) {
46
+ const agent = ((_d = configObject.config) === null || _d === void 0 ? void 0 : _d.editor.agent) || "";
47
+ const path = ((_e = configObject.config) === null || _e === void 0 ? void 0 : _e.dirPath) || "";
48
+ const steps = configObject.exercises.map((e, index) => ({
49
+ slug: e.slug,
50
+ position: e.position || index,
51
+ files: e.files,
52
+ ai_interactions: [],
53
+ compilations: [],
54
+ tests: [],
55
+ is_testeable: e.graded || false,
56
+ }));
57
+ if (path && steps.length > 0) {
58
+ telemetry_1.default.start(agent, steps, path);
59
+ }
60
+ if (config.telemetry) {
61
+ telemetry_1.default.urls = config.telemetry;
62
+ }
63
+ }
45
64
  socket_1.default.start(config, server, false);
46
65
  socket_1.default.on("open", (data) => {
47
66
  console_1.default.debug("Opening these files: ", data);
@@ -91,11 +110,12 @@ class StartCommand extends SessionCommand_1.default {
91
110
  socket: socket_1.default,
92
111
  configuration: config,
93
112
  exercise,
113
+ telemetry: telemetry_1.default,
94
114
  });
95
115
  });
96
- socket_1.default.on("telemetry", (data) => {
97
- console_1.default.info("Registering telemetry event: ", data);
98
- telemetry_1.default.registerEvent(data);
116
+ socket_1.default.on("ai_interaction", (data) => {
117
+ const { stepPosition, event, eventData } = data;
118
+ telemetry_1.default.registerStepEvent(stepPosition, event, eventData);
99
119
  });
100
120
  socket_1.default.on("test", async (data) => {
101
121
  var _a, _b;
@@ -116,12 +136,14 @@ class StartCommand extends SessionCommand_1.default {
116
136
  socket: socket_1.default,
117
137
  configuration: config,
118
138
  exercise,
139
+ telemetry: telemetry_1.default,
119
140
  });
120
141
  (_b = this.configManager) === null || _b === void 0 ? void 0 : _b.save();
121
142
  return true;
122
143
  });
123
144
  const terminate = () => {
124
145
  console_1.default.debug("Terminating Learnpack...");
146
+ telemetry_1.default.submit();
125
147
  server.terminate(() => {
126
148
  var _a;
127
149
  (_a = this.configManager) === null || _a === void 0 ? void 0 : _a.noCurrentExercise();
@@ -9,6 +9,7 @@ const fileQueue_1 = require("../../utils/fileQueue");
9
9
  // import gitpod from '../gitpod'
10
10
  const exercise_1 = require("../config/exercise");
11
11
  const session_1 = require("../../managers/session");
12
+ const telemetry_1 = require("../telemetry");
12
13
  const withHandler = (func) => (req, res) => {
13
14
  try {
14
15
  func(req, res);
@@ -117,9 +118,15 @@ async function default_1(app, configObject, configManager) {
117
118
  req.params.slug === configObject.currentExercise) {
118
119
  const exercise = configManager.getExercise(req.params.slug);
119
120
  res.json(exercise);
121
+ if (exercise.position) {
122
+ telemetry_1.default.registerStepEvent(exercise.position, "open_step", {});
123
+ }
120
124
  return;
121
125
  }
122
126
  const exercise = configManager.startExercise(req.params.slug);
127
+ if (exercise.position) {
128
+ telemetry_1.default.registerStepEvent(exercise.position, "open_step", {});
129
+ }
123
130
  dispatcher.enqueue(dispatcher.events.START_EXERCISE, req.params.slug);
124
131
  const entries = new Set(Object.keys(config === null || config === void 0 ? void 0 : config.entries).map(lang => config === null || config === void 0 ? void 0 : config.entries[lang]));
125
132
  // if we are in incremental grading, the entry file can by dinamically detected
@@ -7,6 +7,7 @@ const errors_1 = require("../utils/errors");
7
7
  const fs = require("fs");
8
8
  const cli_ux_1 = require("cli-ux");
9
9
  const storage = require("node-persist");
10
+ const telemetry_1 = require("./telemetry");
10
11
  const Session = {
11
12
  sessionStarted: false,
12
13
  token: null,
@@ -92,6 +93,11 @@ const Session = {
92
93
  const data = await api_1.default.login(email, password);
93
94
  if (data) {
94
95
  this.start({ token: data.token, payload: data });
96
+ telemetry_1.default.setStudent({
97
+ user_id: data.user_id,
98
+ email: data.email,
99
+ token: data.token,
100
+ });
95
101
  return data;
96
102
  }
97
103
  },
@@ -1,26 +1,73 @@
1
- declare type TEventType = "form_submit" | "build" | "test" | "visualize_step" | "code_start";
2
- declare type TFile = {
3
- name: string;
4
- content: string;
1
+ import { IFile } from "../models/file";
2
+ declare type TCompilationAttempt = {
3
+ source_code: string;
4
+ stdout: string;
5
+ exit_code: number;
6
+ starting_at: number;
7
+ ending_at: number;
5
8
  };
6
- declare type TStep = {
7
- name: string;
9
+ declare type TTestAttempt = {
10
+ source_code: string;
11
+ stdout: string;
12
+ exit_code: number;
13
+ starting_at: number;
14
+ ending_at: number;
15
+ };
16
+ declare type TAIInteraction = {
17
+ student_message: string;
18
+ source_code: string;
19
+ ai_response: string;
20
+ starting_at: number;
21
+ ending_at: number;
22
+ };
23
+ export declare type TStep = {
24
+ slug: string;
8
25
  position: number;
9
- files: TFile[];
26
+ files: IFile[];
27
+ is_testeable: boolean;
28
+ opened_at?: number;
29
+ completed_at?: number;
30
+ compilations: TCompilationAttempt[];
31
+ tests: TTestAttempt[];
32
+ ai_interactions: TAIInteraction[];
33
+ };
34
+ declare type TWorkoutSession = {
35
+ started_at: number;
36
+ ended_at?: number;
10
37
  };
11
- declare type TTelemetryEvent = {
12
- type: TEventType;
13
- data: any;
14
- timestamp: number;
15
- step: TStep;
38
+ declare type TStudent = {
39
+ token: string;
40
+ user_id: string;
41
+ email: string;
42
+ };
43
+ export interface ITelemetryJSONSchema {
44
+ telemetry_id?: string;
45
+ student?: TStudent;
46
+ agent?: string;
47
+ tutorial_started_at?: number;
48
+ last_interaction_at?: number;
49
+ steps: Array<TStep>;
50
+ workout_session: TWorkoutSession[];
51
+ }
52
+ declare type TStepEvent = "compile" | "test" | "ai_interaction" | "open_step";
53
+ export declare type TTelemetryUrls = {
54
+ streaming?: string;
55
+ batch?: string;
16
56
  };
17
57
  interface ITelemetryManager {
18
- current: Array<TTelemetryEvent> | null;
19
- start: () => void;
20
- submit: () => void;
21
- registerEvent: (event: Omit<TTelemetryEvent, "timestamp">) => void;
58
+ current: ITelemetryJSONSchema | null;
59
+ configPath: string | null;
60
+ urls: TTelemetryUrls;
61
+ salute: (message: string) => void;
62
+ start: (agent: string, steps: TStep[], path: string) => void;
63
+ prevStep?: number;
64
+ registerStepEvent: (stepPosition: number, event: TStepEvent, data: any) => void;
65
+ streamEvent: (stepPosition: number, event: string, data: any) => void;
66
+ submit: () => Promise<void>;
67
+ finishWorkoutSession: () => void;
68
+ setStudent: (student: TStudent) => void;
22
69
  save: () => void;
23
- onSaveCallback?: (current: Array<TTelemetryEvent>) => void;
70
+ retrieve: (agent: string, steps: TStep[]) => Promise<ITelemetryJSONSchema | null>;
24
71
  }
25
72
  declare const TelemetryManager: ITelemetryManager;
26
73
  export default TelemetryManager;
@@ -1,25 +1,218 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const fs = require("fs");
4
+ function createUUID() {
5
+ return (Math.random().toString(36).slice(2, 10) +
6
+ Math.random().toString(36).slice(2, 10));
7
+ }
8
+ function stringToBase64(input) {
9
+ return Buffer.from(input).toString("base64");
10
+ }
3
11
  const TelemetryManager = {
4
12
  current: null,
5
- start: function () {
13
+ urls: {},
14
+ configPath: "",
15
+ salute: message => {
16
+ console.log(message);
17
+ },
18
+ start: function (agent, steps, path) {
19
+ this.configPath = path;
6
20
  if (!this.current) {
7
- this.current = [];
21
+ this.retrieve(agent, steps)
22
+ .then(data => {
23
+ const prevTelemetry = data;
24
+ if (prevTelemetry) {
25
+ this.current = prevTelemetry;
26
+ this.finishWorkoutSession();
27
+ }
28
+ else {
29
+ this.current = {
30
+ telemetry_id: createUUID(),
31
+ agent,
32
+ tutorial_started_at: Date.now(),
33
+ steps,
34
+ workout_session: [
35
+ {
36
+ started_at: Date.now(),
37
+ },
38
+ ],
39
+ };
40
+ }
41
+ this.save();
42
+ this.submit();
43
+ })
44
+ .catch(error => {
45
+ console.error(error);
46
+ });
8
47
  }
9
48
  },
10
- submit: function () {
11
- console.log("submit");
49
+ setStudent: function (student) {
50
+ if (!this.current) {
51
+ return;
52
+ }
53
+ this.current.student = student;
54
+ this.save();
55
+ this.submit();
12
56
  },
13
- registerEvent: function (event) {
14
- var _a;
15
- this.start();
16
- const timestamp = Date.now();
17
- const _event = Object.assign(Object.assign({}, event), { timestamp });
18
- (_a = this.current) === null || _a === void 0 ? void 0 : _a.push(_event);
57
+ finishWorkoutSession: function () {
58
+ var _a, _b;
59
+ if (!this.current) {
60
+ return;
61
+ }
62
+ const lastSession = (_a = this.current) === null || _a === void 0 ? void 0 : _a.workout_session[this.current.workout_session.length - 1];
63
+ if (lastSession &&
64
+ !lastSession.ended_at && ((_b = this.current) === null || _b === void 0 ? void 0 : _b.last_interaction_at)) {
65
+ lastSession.ended_at = this.current.last_interaction_at;
66
+ this.current.workout_session.push({
67
+ started_at: Date.now(),
68
+ });
69
+ }
70
+ },
71
+ registerStepEvent: function (stepPosition, event, data) {
72
+ if (!this.current) {
73
+ // throw new Error("Telemetry has not been started");
74
+ return;
75
+ }
76
+ const step = this.current.steps[stepPosition];
77
+ if (!step) {
78
+ return;
79
+ }
80
+ if (data.source_code) {
81
+ data.source_code = stringToBase64(data.source_code);
82
+ }
83
+ if (data.stdout) {
84
+ data.stdout = stringToBase64(data.stdout);
85
+ }
86
+ if (data.stderr) {
87
+ data.stderr = stringToBase64(data.stderr);
88
+ }
89
+ switch (event) {
90
+ case "compile":
91
+ if (!step.compilations) {
92
+ step.compilations = [];
93
+ }
94
+ step.compilations.push(data);
95
+ this.current.steps[stepPosition] = step;
96
+ break;
97
+ case "test":
98
+ if (!step.tests) {
99
+ step.tests = [];
100
+ }
101
+ // data.stdout =
102
+ step.tests.push(data);
103
+ if (data.exit_code === 0) {
104
+ step.completed_at = Date.now();
105
+ }
106
+ this.current.steps[stepPosition] = step;
107
+ break;
108
+ case "ai_interaction":
109
+ if (!step.ai_interactions) {
110
+ step.ai_interactions = [];
111
+ }
112
+ step.ai_interactions.push(data);
113
+ break;
114
+ case "open_step": {
115
+ const now = Date.now();
116
+ if (!step.opened_at) {
117
+ step.opened_at = now;
118
+ this.current.steps[stepPosition] = step;
119
+ }
120
+ if (this.prevStep || this.prevStep === 0) {
121
+ const prevStep = this.current.steps[this.prevStep];
122
+ if (!prevStep.is_testeable && !prevStep.completed_at) {
123
+ prevStep.completed_at = now;
124
+ this.current.steps[this.prevStep] = prevStep;
125
+ }
126
+ }
127
+ this.prevStep = stepPosition;
128
+ this.submit();
129
+ break;
130
+ }
131
+ default:
132
+ throw new Error(`Event type ${event} is not supported`);
133
+ }
134
+ this.current.last_interaction_at = Date.now();
135
+ this.streamEvent(stepPosition, event, data);
136
+ this.save();
137
+ },
138
+ retrieve: function () {
139
+ return new Promise((resolve, reject) => {
140
+ fs.readFile(`${this.configPath}/telemetry.json`, "utf8", (err, data) => {
141
+ if (err) {
142
+ if (err.code === "ENOENT") {
143
+ // File does not exist, resolve with undefined
144
+ resolve(null);
145
+ }
146
+ else {
147
+ reject(err);
148
+ }
149
+ }
150
+ else {
151
+ resolve(JSON.parse(data));
152
+ }
153
+ });
154
+ });
155
+ },
156
+ submit: async function () {
157
+ if (!this.current)
158
+ return Promise.resolve();
159
+ const url = this.urls.batch;
160
+ if (!url) {
161
+ return;
162
+ // throw new Error("Batch URL not specified");
163
+ }
164
+ const body = this.current;
165
+ fetch(url, {
166
+ method: "POST",
167
+ headers: {
168
+ "Content-Type": "application/json",
169
+ },
170
+ body: JSON.stringify(body),
171
+ })
172
+ .then(response => {
173
+ return response.text();
174
+ })
175
+ .catch(error => {
176
+ console.log("Error", error);
177
+ });
19
178
  },
20
179
  save: function () {
21
- if (this.onSaveCallback && this.current) {
22
- this.onSaveCallback(this.current);
180
+ fs.writeFile(`${this.configPath}/telemetry.json`, JSON.stringify(this.current), (err) => {
181
+ if (err)
182
+ throw err;
183
+ });
184
+ },
185
+ streamEvent: async function (stepPosition, event, data) {
186
+ var _a;
187
+ if (!this.current)
188
+ return;
189
+ const url = this.urls.streaming;
190
+ if (!url) {
191
+ return;
192
+ // throw new Error("Streaming URL not specified");
193
+ }
194
+ const stepSlug = this.current.steps[stepPosition].slug;
195
+ const body = {
196
+ slug: stepSlug,
197
+ telemetry_id: this.current.telemetry_id,
198
+ user_id: (_a = this.current.student) === null || _a === void 0 ? void 0 : _a.user_id,
199
+ step_position: stepPosition,
200
+ event,
201
+ data,
202
+ };
203
+ try {
204
+ const response = await fetch(url, {
205
+ method: "POST",
206
+ headers: {
207
+ "Content-Type": "application/json",
208
+ },
209
+ body: JSON.stringify(body),
210
+ });
211
+ const responseText = await response.text();
212
+ }
213
+ catch (error) {
214
+ error;
215
+ // Console.error(error);
23
216
  }
24
217
  },
25
218
  };
@@ -1,4 +1,5 @@
1
1
  import { IExercise } from "./exercise-obj";
2
+ import { TTelemetryUrls } from "../managers/telemetry";
2
3
  export declare type TGrading = "isolated" | "incremental" | "no-grading";
3
4
  export declare type TMode = "preview" | "standalone";
4
5
  export declare type TConfigAction = "test" | "build" | "tutorial" | "reset" | "generate";
@@ -49,6 +50,7 @@ export interface IConfig {
49
50
  bugsLink?: string;
50
51
  videoSolutions?: boolean;
51
52
  skills: Array<string>;
53
+ telemetry?: TTelemetryUrls;
52
54
  runHook: (...agrs: Array<any>) => void;
53
55
  testingFinishedCallback: (arg: any | undefined) => void;
54
56
  }
@@ -6,6 +6,11 @@ export interface IStartProps {
6
6
  token: string;
7
7
  payload: IPayload | null;
8
8
  }
9
+ declare type TLoginResponse = {
10
+ token: string;
11
+ user_id: string;
12
+ email: string;
13
+ };
9
14
  export interface ISession {
10
15
  sessionStarted: boolean;
11
16
  token: string | null;
@@ -18,8 +23,9 @@ export interface ISession {
18
23
  isActive: () => boolean;
19
24
  get: (config?: IConfigObj) => Promise<any>;
20
25
  login: () => Promise<void>;
21
- loginWeb: (email: string, password: string) => Promise<void>;
26
+ loginWeb: (email: string, password: string) => Promise<TLoginResponse>;
22
27
  sync: () => Promise<void>;
23
28
  start: ({ token, payload }: IStartProps) => Promise<void>;
24
29
  destroy: () => Promise<void>;
25
30
  }
31
+ export {};
@@ -1 +1 @@
1
- {"version":"2.1.32","commands":{"audit":{"id":"audit","description":"learnpack audit is the command in charge of creating an auditory of the repository\n...\nlearnpack audit checks for the following information in a repository:\n 1. The configuration object has slug, repository and description. (Error)\n 2. The command learnpack clean has been run. (Error)\n 3. If a markdown or test file doesn't have any content. (Error)\n 4. The links are accessing to valid servers. (Error)\n 5. The relative images are working (If they have the shortest path to the image or if the images exists in the assets). (Error)\n 6. The external images are working (If they are pointing to a valid server). (Error)\n 7. The exercises directory names are valid. (Error)\n 8. If an exercise doesn't have a README file. (Error)\n 9. The exercises array (Of the config file) has content. (Error)\n 10. The exercses have the same translations. (Warning)\n 11. The .gitignore file exists. (Warning)\n 12. If there is a file within the exercises folder but not inside of any particular exercise's folder. (Warning)\n","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{},"args":[]},"clean":{"id":"clean","description":"Clean the configuration object\n ...\n Extra documentation goes here\n ","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{},"args":[]},"download":{"id":"download","description":"Describe the command here\n...\nExtra documentation goes here\n","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{},"args":[{"name":"package","description":"The unique string that identifies this package on learnpack","required":false,"hidden":false}]},"init":{"id":"init","description":"Create a new learning package: Book, Tutorial or Exercise","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{"grading":{"name":"grading","type":"boolean","char":"h","description":"show CLI help","allowNo":false}},"args":[]},"login":{"id":"login","description":"Describe the command here\n ...\n Extra documentation goes here\n ","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{},"args":[{"name":"package","description":"The unique string that identifies this package on learnpack","required":false,"hidden":false}]},"logout":{"id":"logout","description":"Describe the command here\n ...\n Extra documentation goes here\n ","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{},"args":[{"name":"package","description":"The unique string that identifies this package on learnpack","required":false,"hidden":false}]},"publish":{"id":"publish","description":"Describe the command here\n ...\n Extra documentation goes here\n ","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{},"args":[{"name":"package","description":"The unique string that identifies this package on learnpack","required":false,"hidden":false}]},"start":{"id":"start","description":"Runs a small server with all the exercise instructions","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{"port":{"name":"port","type":"option","char":"p","description":"server port"},"host":{"name":"host","type":"option","char":"h","description":"server host"},"disableGrading":{"name":"disableGrading","type":"boolean","char":"D","description":"disble grading functionality","allowNo":false},"watch":{"name":"watch","type":"boolean","char":"w","description":"Watch for file changes","allowNo":false},"editor":{"name":"editor","type":"option","char":"e","description":"[standalone, gitpod]","options":["standalone","gitpod"]},"version":{"name":"version","type":"option","char":"v","description":"E.g: 1.0.1"},"grading":{"name":"grading","type":"option","char":"g","description":"[isolated, incremental]","options":["isolated","incremental"]},"debug":{"name":"debug","type":"boolean","char":"d","description":"debugger mode for more verbage","allowNo":false}},"args":[]},"test":{"id":"test","description":"Test exercises","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{},"args":[{"name":"exerciseSlug","description":"The name of the exercise to test","required":false,"hidden":false}]}}}
1
+ {"version":"2.1.33","commands":{"audit":{"id":"audit","description":"learnpack audit is the command in charge of creating an auditory of the repository\n...\nlearnpack audit checks for the following information in a repository:\n 1. The configuration object has slug, repository and description. (Error)\n 2. The command learnpack clean has been run. (Error)\n 3. If a markdown or test file doesn't have any content. (Error)\n 4. The links are accessing to valid servers. (Error)\n 5. The relative images are working (If they have the shortest path to the image or if the images exists in the assets). (Error)\n 6. The external images are working (If they are pointing to a valid server). (Error)\n 7. The exercises directory names are valid. (Error)\n 8. If an exercise doesn't have a README file. (Error)\n 9. The exercises array (Of the config file) has content. (Error)\n 10. The exercses have the same translations. (Warning)\n 11. The .gitignore file exists. (Warning)\n 12. If there is a file within the exercises folder but not inside of any particular exercise's folder. (Warning)\n","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{},"args":[]},"clean":{"id":"clean","description":"Clean the configuration object\n ...\n Extra documentation goes here\n ","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{},"args":[]},"download":{"id":"download","description":"Describe the command here\n...\nExtra documentation goes here\n","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{},"args":[{"name":"package","description":"The unique string that identifies this package on learnpack","required":false,"hidden":false}]},"init":{"id":"init","description":"Create a new learning package: Book, Tutorial or Exercise","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{"grading":{"name":"grading","type":"boolean","char":"h","description":"show CLI help","allowNo":false}},"args":[]},"login":{"id":"login","description":"Describe the command here\n ...\n Extra documentation goes here\n ","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{},"args":[{"name":"package","description":"The unique string that identifies this package on learnpack","required":false,"hidden":false}]},"logout":{"id":"logout","description":"Describe the command here\n ...\n Extra documentation goes here\n ","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{},"args":[{"name":"package","description":"The unique string that identifies this package on learnpack","required":false,"hidden":false}]},"publish":{"id":"publish","description":"Describe the command here\n ...\n Extra documentation goes here\n ","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{},"args":[{"name":"package","description":"The unique string that identifies this package on learnpack","required":false,"hidden":false}]},"start":{"id":"start","description":"Runs a small server with all the exercise instructions","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{"port":{"name":"port","type":"option","char":"p","description":"server port"},"host":{"name":"host","type":"option","char":"h","description":"server host"},"disableGrading":{"name":"disableGrading","type":"boolean","char":"D","description":"disble grading functionality","allowNo":false},"watch":{"name":"watch","type":"boolean","char":"w","description":"Watch for file changes","allowNo":false},"editor":{"name":"editor","type":"option","char":"e","description":"[standalone, gitpod]","options":["standalone","gitpod"]},"version":{"name":"version","type":"option","char":"v","description":"E.g: 1.0.1"},"grading":{"name":"grading","type":"option","char":"g","description":"[isolated, incremental]","options":["isolated","incremental"]},"debug":{"name":"debug","type":"boolean","char":"d","description":"debugger mode for more verbage","allowNo":false}},"args":[]},"test":{"id":"test","description":"Test exercises","pluginName":"@learnpack/learnpack","pluginType":"core","aliases":[],"flags":{},"args":[{"name":"exerciseSlug","description":"The name of the exercise to test","required":false,"hidden":false}]}}}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@learnpack/learnpack",
3
3
  "description": "Create, sell or download and take learning amazing learning packages",
4
- "version": "2.1.32",
4
+ "version": "2.1.33",
5
5
  "author": "Alejandro Sanchez @alesanchezr",
6
6
  "bin": {
7
7
  "learnpack": "bin/run"
@@ -4,8 +4,8 @@ import { flags } from "@oclif/command"
4
4
  import SessionCommand from "../utils/SessionCommand"
5
5
  import Console from "../utils/console"
6
6
  import socket from "../managers/socket"
7
- import TelemetryManager from "../managers/telemetry"
8
-
7
+ import TelemetryManager, { TStep } from "../managers/telemetry"
8
+ import createServer from "../managers/server"
9
9
  import queue from "../utils/fileQueue"
10
10
  import {
11
11
  decompress,
@@ -14,8 +14,6 @@ import {
14
14
  } from "../managers/file"
15
15
  import { prioritizeHTMLFile } from "../utils/misc"
16
16
 
17
- import createServer from "../managers/server"
18
-
19
17
  import { IGitpodData } from "../models/gitpod-data"
20
18
  import { IExercise, IExerciseData } from "../models/exercise-obj"
21
19
 
@@ -118,6 +116,30 @@ export default class StartCommand extends SessionCommand {
118
116
  path: `${config.dirPath}/vscode_queue.json`,
119
117
  })
120
118
 
119
+ if (configObject.exercises) {
120
+ const agent = configObject.config?.editor.agent || ""
121
+ const path = configObject.config?.dirPath || ""
122
+
123
+ const steps = configObject.exercises.map(
124
+ (e: IExercise, index): TStep => ({
125
+ slug: e.slug,
126
+ position: e.position || index,
127
+ files: e.files,
128
+ ai_interactions: [],
129
+ compilations: [],
130
+ tests: [],
131
+ is_testeable: e.graded || false,
132
+ })
133
+ )
134
+ if (path && steps.length > 0) {
135
+ TelemetryManager.start(agent, steps, path)
136
+ }
137
+
138
+ if (config.telemetry) {
139
+ TelemetryManager.urls = config.telemetry
140
+ }
141
+ }
142
+
121
143
  socket.start(config, server, false)
122
144
 
123
145
  socket.on("open", (data: IGitpodData) => {
@@ -184,12 +206,13 @@ export default class StartCommand extends SessionCommand {
184
206
  socket,
185
207
  configuration: config,
186
208
  exercise,
209
+ telemetry: TelemetryManager,
187
210
  })
188
211
  })
189
212
 
190
- socket.on("telemetry", (data: any) => {
191
- Console.info("Registering telemetry event: ", data)
192
- TelemetryManager.registerEvent(data)
213
+ socket.on("ai_interaction", (data: any) => {
214
+ const { stepPosition, event, eventData } = data
215
+ TelemetryManager.registerStepEvent(stepPosition, event, eventData)
193
216
  })
194
217
 
195
218
  socket.on("test", async (data: IExerciseData) => {
@@ -223,6 +246,7 @@ export default class StartCommand extends SessionCommand {
223
246
  socket,
224
247
  configuration: config,
225
248
  exercise,
249
+ telemetry: TelemetryManager,
226
250
  })
227
251
 
228
252
  this.configManager?.save()
@@ -232,6 +256,9 @@ export default class StartCommand extends SessionCommand {
232
256
 
233
257
  const terminate = () => {
234
258
  Console.debug("Terminating Learnpack...")
259
+
260
+ TelemetryManager.submit()
261
+
235
262
  server.terminate(() => {
236
263
  this.configManager?.noCurrentExercise()
237
264
  dispatcher.enqueue(dispatcher.events.END)
@@ -11,6 +11,7 @@ import { IConfigObj, TEntries } from "../../models/config"
11
11
  import { IConfigManager } from "../../models/config-manager"
12
12
  import { IExercise } from "../../models/exercise-obj"
13
13
  import SessionManager from "../../managers/session"
14
+ import TelemetryManager from "../telemetry"
14
15
 
15
16
  const withHandler =
16
17
  (func: (req: express.Request, res: express.Response) => void) =>
@@ -177,10 +178,22 @@ export default async function (
177
178
  ) {
178
179
  const exercise = configManager.getExercise(req.params.slug)
179
180
  res.json(exercise)
181
+ if (exercise.position) {
182
+ TelemetryManager.registerStepEvent(
183
+ exercise.position,
184
+ "open_step",
185
+ {}
186
+ )
187
+ }
188
+
180
189
  return
181
190
  }
182
191
 
183
192
  const exercise = configManager.startExercise(req.params.slug)
193
+ if (exercise.position) {
194
+ TelemetryManager.registerStepEvent(exercise.position, "open_step", {})
195
+ }
196
+
184
197
  dispatcher.enqueue(dispatcher.events.START_EXERCISE, req.params.slug)
185
198
 
186
199
  type TEntry = "python3" | "html" | "node" | "react" | "java";
@@ -10,6 +10,7 @@ import * as storage from "node-persist"
10
10
 
11
11
  import { IPayload, ISession, IStartProps } from "../models/session"
12
12
  import { IConfigObj } from "../models/config"
13
+ import TelemetryManager from "./telemetry"
13
14
 
14
15
  const Session: ISession = {
15
16
  sessionStarted: false,
@@ -109,6 +110,11 @@ const Session: ISession = {
109
110
  const data = await api.login(email, password)
110
111
  if (data) {
111
112
  this.start({ token: data.token, payload: data })
113
+ TelemetryManager.setStudent({
114
+ user_id: data.user_id,
115
+ email: data.email,
116
+ token: data.token,
117
+ })
112
118
  return data
113
119
  }
114
120
  },
@@ -1,56 +1,344 @@
1
- type TEventType =
2
- | "form_submit"
3
- | "build"
4
- | "test"
5
- | "visualize_step"
6
- | "code_start";
7
-
8
- type TFile = {
9
- name: string;
10
- content: string;
1
+ import { IFile } from "../models/file"
2
+
3
+ const fs = require("fs")
4
+
5
+ function createUUID(): string {
6
+ return (
7
+ Math.random().toString(36).slice(2, 10) +
8
+ Math.random().toString(36).slice(2, 10)
9
+ )
10
+ }
11
+
12
+ function stringToBase64(input: string): string {
13
+ return Buffer.from(input).toString("base64")
14
+ }
15
+
16
+ type TCompilationAttempt = {
17
+ source_code: string;
18
+ stdout: string;
19
+ exit_code: number;
20
+ starting_at: number;
21
+ ending_at: number;
22
+ };
23
+
24
+ type TTestAttempt = {
25
+ source_code: string;
26
+ stdout: string;
27
+ exit_code: number;
28
+ starting_at: number;
29
+ ending_at: number;
11
30
  };
12
31
 
13
- type TStep = {
14
- name: string;
32
+ type TAIInteraction = {
33
+ student_message: string;
34
+ source_code: string;
35
+ ai_response: string;
36
+ starting_at: number;
37
+ ending_at: number;
38
+ };
39
+
40
+ export type TStep = {
41
+ slug: string;
15
42
  position: number;
16
- files: TFile[];
43
+ files: IFile[];
44
+ is_testeable: boolean;
45
+ opened_at?: number; // The time when the step was opened
46
+ completed_at?: number; // If the step has tests, the time when all the tests passed, else, the time when the user opens the next step
47
+ compilations: TCompilationAttempt[]; // Everytime the user tries to compile the code
48
+ tests: TTestAttempt[]; // Everytime the user tries to run the tests
49
+ ai_interactions: TAIInteraction[]; // Everytime the user interacts with the AI
50
+ };
51
+
52
+ type TWorkoutSession = {
53
+ started_at: number;
54
+ ended_at?: number;
17
55
  };
18
56
 
19
- type TTelemetryEvent = {
20
- type: TEventType;
21
- data: any;
22
- timestamp: number;
23
- step: TStep;
57
+ type TStudent = {
58
+ token: string;
59
+ user_id: string;
60
+ email: string;
61
+ };
62
+
63
+ export interface ITelemetryJSONSchema {
64
+ telemetry_id?: string;
65
+ student?: TStudent;
66
+ agent?: string;
67
+ tutorial_started_at?: number;
68
+ last_interaction_at?: number;
69
+ steps: Array<TStep>; // The steps should be the same as the exercise
70
+ workout_session: TWorkoutSession[]; // It start when the user starts Learnpack, if the last_interaction_at is available, it automatically fills with that
71
+ // number and start another session
72
+ }
73
+
74
+ type TStepEvent = "compile" | "test" | "ai_interaction" | "open_step";
75
+
76
+ export type TTelemetryUrls = {
77
+ streaming?: string;
78
+ batch?: string;
24
79
  };
25
80
 
26
81
  interface ITelemetryManager {
27
- current: Array<TTelemetryEvent> | null;
28
- start: () => void;
29
- submit: () => void;
30
- registerEvent: (event: Omit<TTelemetryEvent, "timestamp">) => void;
82
+ current: ITelemetryJSONSchema | null;
83
+ configPath: string | null;
84
+ urls: TTelemetryUrls;
85
+ salute: (message: string) => void;
86
+ start: (agent: string, steps: TStep[], path: string) => void;
87
+ prevStep?: number;
88
+ registerStepEvent: (
89
+ stepPosition: number,
90
+ event: TStepEvent,
91
+ data: any
92
+ ) => void;
93
+ streamEvent: (stepPosition: number, event: string, data: any) => void;
94
+ submit: () => Promise<void>;
95
+ finishWorkoutSession: () => void;
96
+ setStudent: (student: TStudent) => void;
31
97
  save: () => void;
32
- onSaveCallback?: (current: Array<TTelemetryEvent>) => void;
98
+ retrieve: (
99
+ agent: string,
100
+ steps: TStep[]
101
+ ) => Promise<ITelemetryJSONSchema | null>;
33
102
  }
34
103
 
35
104
  const TelemetryManager: ITelemetryManager = {
36
105
  current: null,
37
- start: function () {
106
+ urls: {},
107
+ configPath: "",
108
+ salute: message => {
109
+ console.log(message)
110
+ },
111
+
112
+ start: function (agent, steps, path) {
113
+ this.configPath = path
114
+ if (!this.current) {
115
+ this.retrieve(agent, steps)
116
+ .then(data => {
117
+ const prevTelemetry = data
118
+ if (prevTelemetry) {
119
+ this.current = prevTelemetry
120
+ this.finishWorkoutSession()
121
+ } else {
122
+ this.current = {
123
+ telemetry_id: createUUID(),
124
+ agent,
125
+ tutorial_started_at: Date.now(),
126
+ steps,
127
+ workout_session: [
128
+ {
129
+ started_at: Date.now(),
130
+ },
131
+ ],
132
+ }
133
+ }
134
+
135
+ this.save()
136
+ this.submit()
137
+ })
138
+ .catch(error => {
139
+ console.error(error)
140
+ })
141
+ }
142
+ },
143
+
144
+ setStudent: function (student) {
145
+ if (!this.current) {
146
+ return
147
+ }
148
+
149
+ this.current.student = student
150
+ this.save()
151
+ this.submit()
152
+ },
153
+ finishWorkoutSession: function () {
154
+ if (!this.current) {
155
+ return
156
+ }
157
+
158
+ const lastSession =
159
+ this.current?.workout_session[this.current.workout_session.length - 1]
160
+ if (
161
+ lastSession &&
162
+ !lastSession.ended_at &&
163
+ this.current?.last_interaction_at
164
+ ) {
165
+ lastSession.ended_at = this.current.last_interaction_at
166
+ this.current.workout_session.push({
167
+ started_at: Date.now(),
168
+ })
169
+ }
170
+ },
171
+
172
+ registerStepEvent: function (stepPosition, event, data) {
38
173
  if (!this.current) {
39
- this.current = []
174
+ // throw new Error("Telemetry has not been started");
175
+ return
176
+ }
177
+
178
+ const step = this.current.steps[stepPosition]
179
+ if (!step) {
180
+ return
181
+ }
182
+
183
+ if (data.source_code) {
184
+ data.source_code = stringToBase64(data.source_code)
185
+ }
186
+
187
+ if (data.stdout) {
188
+ data.stdout = stringToBase64(data.stdout)
189
+ }
190
+
191
+ if (data.stderr) {
192
+ data.stderr = stringToBase64(data.stderr)
193
+ }
194
+
195
+ switch (event) {
196
+ case "compile":
197
+ if (!step.compilations) {
198
+ step.compilations = []
199
+ }
200
+
201
+ step.compilations.push(data)
202
+ this.current.steps[stepPosition] = step
203
+ break
204
+ case "test":
205
+ if (!step.tests) {
206
+ step.tests = []
207
+ }
208
+
209
+ // data.stdout =
210
+ step.tests.push(data)
211
+ if (data.exit_code === 0) {
212
+ step.completed_at = Date.now()
213
+ }
214
+
215
+ this.current.steps[stepPosition] = step
216
+ break
217
+ case "ai_interaction":
218
+ if (!step.ai_interactions) {
219
+ step.ai_interactions = []
220
+ }
221
+
222
+ step.ai_interactions.push(data)
223
+ break
224
+ case "open_step": {
225
+ const now = Date.now()
226
+
227
+ if (!step.opened_at) {
228
+ step.opened_at = now
229
+ this.current.steps[stepPosition] = step
230
+ }
231
+
232
+ if (this.prevStep || this.prevStep === 0) {
233
+ const prevStep = this.current.steps[this.prevStep]
234
+ if (!prevStep.is_testeable && !prevStep.completed_at) {
235
+ prevStep.completed_at = now
236
+ this.current.steps[this.prevStep] = prevStep
237
+ }
238
+ }
239
+
240
+ this.prevStep = stepPosition
241
+
242
+ this.submit()
243
+ break
244
+ }
245
+
246
+ default:
247
+ throw new Error(`Event type ${event} is not supported`)
40
248
  }
249
+
250
+ this.current.last_interaction_at = Date.now()
251
+ this.streamEvent(stepPosition, event, data)
252
+ this.save()
41
253
  },
42
- submit: function () {
43
- console.log("submit")
254
+ retrieve: function () {
255
+ return new Promise((resolve, reject) => {
256
+ fs.readFile(
257
+ `${this.configPath}/telemetry.json`,
258
+ "utf8",
259
+ (err: any, data: any) => {
260
+ if (err) {
261
+ if (err.code === "ENOENT") {
262
+ // File does not exist, resolve with undefined
263
+ resolve(null)
264
+ } else {
265
+ reject(err)
266
+ }
267
+ } else {
268
+ resolve(JSON.parse(data))
269
+ }
270
+ }
271
+ )
272
+ })
44
273
  },
45
- registerEvent: function (event) {
46
- this.start()
47
- const timestamp = Date.now()
48
- const _event = { ...event, timestamp }
49
- this.current?.push(_event)
274
+ submit: async function () {
275
+ if (!this.current)
276
+ return Promise.resolve()
277
+ const url = this.urls.batch
278
+ if (!url) {
279
+ return
280
+ // throw new Error("Batch URL not specified");
281
+ }
282
+
283
+ const body = this.current
284
+ fetch(url, {
285
+ method: "POST",
286
+ headers: {
287
+ "Content-Type": "application/json",
288
+ },
289
+ body: JSON.stringify(body),
290
+ })
291
+ .then(response => {
292
+ return response.text()
293
+ })
294
+ .catch(error => {
295
+ console.log("Error", error)
296
+ })
50
297
  },
51
298
  save: function () {
52
- if (this.onSaveCallback && this.current) {
53
- this.onSaveCallback(this.current)
299
+ fs.writeFile(
300
+ `${this.configPath}/telemetry.json`,
301
+ JSON.stringify(this.current),
302
+ (err: any) => {
303
+ if (err)
304
+ throw err
305
+ }
306
+ )
307
+ },
308
+
309
+ streamEvent: async function (stepPosition, event, data) {
310
+ if (!this.current)
311
+ return
312
+
313
+ const url = this.urls.streaming
314
+ if (!url) {
315
+ return
316
+ // throw new Error("Streaming URL not specified");
317
+ }
318
+
319
+ const stepSlug = this.current.steps[stepPosition].slug
320
+
321
+ const body = {
322
+ slug: stepSlug,
323
+ telemetry_id: this.current.telemetry_id,
324
+ user_id: this.current.student?.user_id,
325
+ step_position: stepPosition,
326
+ event,
327
+ data,
328
+ }
329
+
330
+ try {
331
+ const response = await fetch(url, {
332
+ method: "POST",
333
+ headers: {
334
+ "Content-Type": "application/json",
335
+ },
336
+ body: JSON.stringify(body),
337
+ })
338
+ const responseText = await response.text()
339
+ } catch (error) {
340
+ error
341
+ // Console.error(error);
54
342
  }
55
343
  },
56
344
  }
@@ -1,4 +1,5 @@
1
1
  import { IExercise } from "./exercise-obj"
2
+ import { TTelemetryUrls } from "../managers/telemetry"
2
3
 
3
4
  export type TGrading = "isolated" | "incremental" | "no-grading";
4
5
 
@@ -70,6 +71,7 @@ export interface IConfig {
70
71
  bugsLink?: string;
71
72
  videoSolutions?: boolean;
72
73
  skills: Array<string>;
74
+ telemetry?: TTelemetryUrls;
73
75
  runHook: (...agrs: Array<any>) => void;
74
76
  testingFinishedCallback: (arg: any | undefined) => void;
75
77
  }
@@ -9,6 +9,12 @@ export interface IStartProps {
9
9
  payload: IPayload | null;
10
10
  }
11
11
 
12
+ type TLoginResponse = {
13
+ token: string;
14
+ user_id: string;
15
+ email: string;
16
+ };
17
+
12
18
  export interface ISession {
13
19
  sessionStarted: boolean;
14
20
  token: string | null;
@@ -21,7 +27,7 @@ export interface ISession {
21
27
  isActive: () => boolean;
22
28
  get: (config?: IConfigObj) => Promise<any>;
23
29
  login: () => Promise<void>;
24
- loginWeb: (email: string, password: string) => Promise<void>;
30
+ loginWeb: (email: string, password: string) => Promise<TLoginResponse>;
25
31
  sync: () => Promise<void>;
26
32
  start: ({ token, payload }: IStartProps) => Promise<void>;
27
33
  destroy: () => Promise<void>;