@toloka-platform/sdk 0.1.0-preview.4 → 0.1.0-preview.6

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.
Files changed (60) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/toloka.js +132 -126
  3. package/dist/cli/toloka.js.jsc +0 -0
  4. package/dist/cli/toloka.js.jsc.sha256 +1 -1
  5. package/dist/cli/toloka.js.sha256 +1 -1
  6. package/docs/cli-v2.md +4 -0
  7. package/docs/cli.md +82 -6
  8. package/docs/datasets.md +8 -9
  9. package/docs/getting-started.md +2 -2
  10. package/docs/limitations.md +5 -8
  11. package/docs/troubleshooting.md +0 -11
  12. package/openapi/gen/api.ts +782 -876
  13. package/openapi/gen/models/baseModel.ts +9 -0
  14. package/openapi/gen/models/baseModelId.ts +2 -0
  15. package/openapi/gen/models/baseModelIdProperty.ts +2 -0
  16. package/openapi/gen/models/createDatasetBody.ts +2 -0
  17. package/openapi/gen/models/createDatasetBodyProfile.ts +2 -0
  18. package/openapi/gen/models/createDatasetUploadBodyFormat.ts +1 -1
  19. package/openapi/gen/models/createProjectModelBody.ts +8 -0
  20. package/openapi/gen/models/createProjectModelBodyDescription.ts +2 -0
  21. package/openapi/gen/models/dataset.ts +2 -0
  22. package/openapi/gen/models/datasetProfile.ts +3 -0
  23. package/openapi/gen/models/datasetProfileStamp.ts +6 -0
  24. package/openapi/gen/models/datasetProfileStampName.ts +2 -0
  25. package/openapi/gen/models/generationNodeSettingsAudienceAndPricingOneOfOneone.ts +1 -0
  26. package/openapi/gen/models/index.ts +21 -0
  27. package/openapi/gen/models/listBaseModels200.ts +5 -0
  28. package/openapi/gen/models/listModelVersions200.ts +7 -0
  29. package/openapi/gen/models/listModelVersionsParams.ts +5 -0
  30. package/openapi/gen/models/listProjectModels200.ts +7 -0
  31. package/openapi/gen/models/listProjectModelsParams.ts +5 -0
  32. package/openapi/gen/models/model.ts +16 -0
  33. package/openapi/gen/models/modelArchivedAt.ts +2 -0
  34. package/openapi/gen/models/modelDescription.ts +2 -0
  35. package/openapi/gen/models/modelId.ts +2 -0
  36. package/openapi/gen/models/modelVersion.ts +9 -0
  37. package/openapi/gen/models/modelVersionId.ts +2 -0
  38. package/openapi/gen/models/pipelineGraphSnapshotGraphNodesItemOneOfThreeoneSettingsAudienceAndPricingOneOfOneone.ts +1 -0
  39. package/openapi/gen/models/runCost.ts +7 -0
  40. package/openapi/gen/models/validatePipelineGraphBodyGraphNodesItemOneOfThreeoneSettingsAudienceAndPricingOneOfOneone.ts +1 -0
  41. package/package.json +2 -1
  42. package/skills/catalog.json +22 -0
  43. package/skills/{SKILL.md → toloka-build/SKILL.md} +2 -2
  44. package/skills/{references → toloka-build/references}/workflow-and-cli.md +26 -5
  45. package/skills/toloka-report-issue/SKILL.md +25 -0
  46. package/skills/toloka-report-issue/references/report-template.md +34 -0
  47. package/src/cli/analytics.ts +53 -1
  48. package/src/cli/data.ts +148 -25
  49. package/src/cli/file-system.ts +34 -0
  50. package/src/cli/format-error.ts +19 -0
  51. package/src/cli/init.ts +149 -194
  52. package/src/cli/models.ts +188 -0
  53. package/src/cli/program.ts +202 -22
  54. package/src/cli/report.ts +144 -0
  55. package/src/cli/skill.ts +363 -0
  56. package/src/client.ts +109 -35
  57. package/src/errors.ts +25 -10
  58. package/src/pipeline-graph-v1.contract.json +5 -1
  59. package/src/transport.ts +16 -13
  60. /package/skills/{references → toloka-build/references}/node-semantics.md +0 -0
package/src/cli/data.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { assert } from "es-toolkit";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname } from "node:path";
4
- import type { Dataset, InferredField, PotatoClient } from "../client";
4
+ import type { Dataset, InferredField, PotatoClient, UploadId } from "../client";
5
5
  import { PotatoApiError } from "../errors";
6
6
  import { generateFieldDeclarationsSource } from "../importer";
7
7
  import { readTolokaState } from "../state";
@@ -14,6 +14,14 @@ import type { ProjectSource } from "./source";
14
14
  const RAW_UPLOAD_FIELD_NAME = "uploaded";
15
15
  const DATASET_ID_PATTERN =
16
16
  /^ds\.[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
17
+ const FINALIZE_UPLOAD_ATTEMPTS = 3;
18
+
19
+ class DatasetUploadError extends Error {
20
+ constructor(message: string, cause: unknown) {
21
+ super(message, { cause });
22
+ this.name = "DatasetUploadError";
23
+ }
24
+ }
17
25
 
18
26
  const canonicalDatasetId = (token: string): string | undefined =>
19
27
  DATASET_ID_PATTERN.test(token) ? token.toLowerCase() : undefined;
@@ -22,6 +30,16 @@ type UploadFieldDeclaration = NonNullable<
22
30
  Parameters<PotatoClient["datasets"]["startUpload"]>[0]["fields"]
23
31
  >[number];
24
32
 
33
+ type DatasetUploadFormat = NonNullable<
34
+ Parameters<PotatoClient["datasets"]["startUpload"]>[0]["format"]
35
+ >;
36
+
37
+ const DATASET_UPLOAD_CONTENT_TYPES: Record<DatasetUploadFormat, string> = {
38
+ json: "application/json",
39
+ jsonl: "application/x-ndjson",
40
+ csv: "text/csv",
41
+ };
42
+
25
43
  export type UploadDatasetOptions = {
26
44
  readonly sourceRoot: string;
27
45
  readonly datasetName: string;
@@ -201,29 +219,138 @@ const resolveLiveDataset = async (
201
219
  return { datasetId: created.id, name: created.name, nameIsUnique: true };
202
220
  };
203
221
 
204
- const readJsonLines = async (filePath: string): Promise<ReadonlyArray<Record<string, unknown>>> => {
205
- const source = await readFile(filePath, "utf8");
206
- return source
207
- .split(/\r?\n/u)
208
- .map((line, index) => ({ line, lineNumber: index + 1 }))
209
- .filter(({ line }) => line.trim().length > 0)
210
- .map(({ line, lineNumber }) => {
222
+ const resolveDatasetUploadFormat = (filename: string): DatasetUploadFormat => {
223
+ const normalizedFilename = filename.toLowerCase();
224
+
225
+ if (normalizedFilename.endsWith(".json")) return "json";
226
+ if (normalizedFilename.endsWith(".jsonl")) return "jsonl";
227
+ if (normalizedFilename.endsWith(".csv")) return "csv";
228
+
229
+ assert(false, `${filename} must have a .json, .jsonl, or .csv extension`);
230
+ };
231
+
232
+ const readDatasetUpload = async (filePath: string) => {
233
+ const filename = basename(filePath);
234
+ const format = resolveDatasetUploadFormat(filename);
235
+ const payload = await readFile(filePath, "utf8");
236
+
237
+ if (format === "jsonl") {
238
+ for (const { line, lineNumber } of payload
239
+ .split(/\r?\n/u)
240
+ .map((line, index) => ({ line, lineNumber: index + 1 }))
241
+ .filter(({ line }) => line.length > 0)) {
211
242
  let value: unknown;
212
243
  try {
213
244
  value = JSON.parse(line);
214
245
  } catch {
215
- assert(false, `${basename(filePath)}:${lineNumber} is not valid JSON`);
246
+ assert(false, `${filename}:${lineNumber} is not valid JSON`);
216
247
  }
217
- assert(isJsonObject(value), `${basename(filePath)}:${lineNumber} must contain a JSON object`);
218
- return value;
248
+ assert(isJsonObject(value), `${filename}:${lineNumber} must contain a JSON object`);
249
+ }
250
+ }
251
+
252
+ return { payload, format, contentType: DATASET_UPLOAD_CONTENT_TYPES[format] };
253
+ };
254
+
255
+ type StartedDatasetUpload = { readonly uploadUrl: string; readonly job: { readonly id: UploadId } };
256
+
257
+ const cancelStartedUpload = async (
258
+ client: PotatoClient,
259
+ uploadId: StartedDatasetUpload["job"]["id"],
260
+ error: unknown,
261
+ ): Promise<never> => {
262
+ const reason = error instanceof Error ? error.message : String(error);
263
+
264
+ try {
265
+ await client.datasets.cancelUpload({ uploadId });
266
+ } catch (cancelError) {
267
+ const cancelReason = cancelError instanceof Error ? cancelError.message : String(cancelError);
268
+ throw new AggregateError(
269
+ [error, cancelError],
270
+ `Dataset upload ${uploadId} failed and could not be canceled. Upload error: ${reason}. Cancellation error: ${cancelReason}.`,
271
+ );
272
+ }
273
+
274
+ throw new DatasetUploadError(
275
+ `Dataset upload ${uploadId} failed and was canceled: ${reason}`,
276
+ error,
277
+ );
278
+ };
279
+
280
+ const transferAndFinalizeUpload = async ({
281
+ client,
282
+ started,
283
+ payload,
284
+ contentType,
285
+ fetch,
286
+ retryIntervalMs,
287
+ }: {
288
+ client: PotatoClient;
289
+ started: StartedDatasetUpload;
290
+ payload: string;
291
+ contentType: string;
292
+ fetch: typeof globalThis.fetch;
293
+ retryIntervalMs: number;
294
+ }): Promise<void> => {
295
+ let uploadResponse: Response;
296
+ try {
297
+ uploadResponse = await fetch(started.uploadUrl, {
298
+ method: "PUT",
299
+ headers: {
300
+ "Content-Type": contentType,
301
+ "x-ms-blob-type": "BlockBlob",
302
+ },
303
+ body: payload,
219
304
  });
305
+ } catch (error) {
306
+ return cancelStartedUpload(client, started.job.id, error);
307
+ }
308
+
309
+ if (!uploadResponse.ok) {
310
+ return cancelStartedUpload(
311
+ client,
312
+ started.job.id,
313
+ new DatasetUploadError(
314
+ `Dataset upload failed with HTTP ${uploadResponse.status}.`,
315
+ undefined,
316
+ ),
317
+ );
318
+ }
319
+
320
+ let transientFailures = 0;
321
+ while (true) {
322
+ try {
323
+ await client.datasets.finalizeUpload({ uploadId: started.job.id });
324
+ return;
325
+ } catch (error) {
326
+ if (!(error instanceof PotatoApiError && error.status === 503)) {
327
+ return cancelStartedUpload(client, started.job.id, error);
328
+ }
329
+
330
+ transientFailures += 1;
331
+ if (transientFailures === FINALIZE_UPLOAD_ATTEMPTS) {
332
+ return cancelStartedUpload(
333
+ client,
334
+ started.job.id,
335
+ new DatasetUploadError(
336
+ `Storage remained unavailable after ${FINALIZE_UPLOAD_ATTEMPTS} finalization attempts.`,
337
+ error,
338
+ ),
339
+ );
340
+ }
341
+
342
+ const { promise, resolve } = Promise.withResolvers<void>();
343
+ setTimeout(resolve, retryIntervalMs);
344
+ await promise;
345
+ }
346
+ }
220
347
  };
221
348
 
222
349
  export async function uploadDataset(
223
350
  client: PotatoClient,
224
351
  options: UploadDatasetOptions,
225
352
  ): Promise<UploadDatasetResult> {
226
- const rows = await readJsonLines(options.filePath);
353
+ const { payload, format, contentType } = await readDatasetUpload(options.filePath);
227
354
  const resolved = await resolveLiveDataset(client, options, true);
228
355
  const datasetId = resolved.datasetId;
229
356
 
@@ -236,25 +363,21 @@ export async function uploadDataset(
236
363
  if (!fieldsByName.has(field.name)) fieldsByName.set(field.name, field);
237
364
  }
238
365
  const fields = [...fieldsByName.values()];
239
- const payload = JSON.stringify(rows);
240
- const payloadBytes = new TextEncoder().encode(payload);
241
366
  const started = await client.datasets.startUpload({
242
367
  datasetId,
243
368
  filename: basename(options.filePath),
244
- format: "json",
245
- declaredSizeBytes: payloadBytes.byteLength,
369
+ format,
370
+ declaredSizeBytes: new TextEncoder().encode(payload).byteLength,
246
371
  fields,
247
372
  });
248
- const uploadResponse = await (options.fetch ?? globalThis.fetch)(started.uploadUrl, {
249
- method: "PUT",
250
- headers: {
251
- "Content-Type": "application/json",
252
- "x-ms-blob-type": "BlockBlob",
253
- },
254
- body: payloadBytes,
373
+ await transferAndFinalizeUpload({
374
+ client,
375
+ started,
376
+ payload,
377
+ contentType,
378
+ fetch: options.fetch ?? globalThis.fetch,
379
+ retryIntervalMs: options.waitFor?.intervalMs ?? 1000,
255
380
  });
256
- assert(uploadResponse.ok, `Dataset upload failed with HTTP ${uploadResponse.status}.`);
257
- await client.datasets.finalizeUpload({ uploadId: started.job.id });
258
381
  const completed = await client.waitFor(
259
382
  { type: "datasetUpload", uploadId: started.job.id },
260
383
  options.waitFor,
@@ -0,0 +1,34 @@
1
+ import { assert } from "es-toolkit";
2
+ import { lstat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ export const hasErrorCode = (error: unknown, ...codes: readonly string[]) =>
6
+ error instanceof Error &&
7
+ "code" in error &&
8
+ typeof error.code === "string" &&
9
+ codes.includes(error.code);
10
+
11
+ export const isMissingFileError = (error: unknown) => hasErrorCode(error, "ENOENT");
12
+
13
+ export const entryStats = async (path: string) => {
14
+ try {
15
+ return await lstat(path);
16
+ } catch (error) {
17
+ if (isMissingFileError(error)) return undefined;
18
+ throw error;
19
+ }
20
+ };
21
+
22
+ export const assertSafeDestination = async (
23
+ targetDirectory: string,
24
+ relativePath: string,
25
+ action = "write",
26
+ ) => {
27
+ let currentPath = targetDirectory;
28
+ for (const segment of relativePath.split("/")) {
29
+ currentPath = join(currentPath, segment);
30
+ const entry = await entryStats(currentPath);
31
+ if (entry === undefined) return;
32
+ assert(!entry.isSymbolicLink(), `Refusing to ${action} through symbolic link ${currentPath}.`);
33
+ }
34
+ };
@@ -0,0 +1,19 @@
1
+ import { PotatoApiError } from "../errors";
2
+
3
+ export const apiErrorDetails = (error: PotatoApiError): string | undefined => {
4
+ const details =
5
+ typeof error.body === "string"
6
+ ? error.body
7
+ : error.body === undefined
8
+ ? undefined
9
+ : JSON.stringify(error.body);
10
+ return details === undefined || details.length === 0 ? undefined : details;
11
+ };
12
+
13
+ export const formatCliError = (error: unknown): string => {
14
+ if (error instanceof PotatoApiError) {
15
+ const details = apiErrorDetails(error);
16
+ return details === undefined ? error.message : `${error.message}\n${details}`;
17
+ }
18
+ return error instanceof Error ? error.message : String(error);
19
+ };