@toloka-platform/sdk 0.2.0-preview.3 → 0.2.0

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 (75) hide show
  1. package/README.md +5 -6
  2. package/dist/cli/toloka.js +136 -136
  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 +3 -2
  7. package/docs/cli.md +35 -7
  8. package/docs/getting-started.md +4 -2
  9. package/docs/limitations.md +2 -2
  10. package/openapi/gen/models/baseModel.ts +1 -0
  11. package/openapi/gen/models/currentUser.ts +2 -0
  12. package/openapi/gen/models/currentUserTeamsItem.ts +6 -0
  13. package/openapi/gen/models/gistingTrainingConfiguration.ts +6 -2
  14. package/openapi/gen/models/gistingTrainingConfigurationBatchSize.ts +2 -0
  15. package/openapi/gen/models/gistingTrainingConfigurationGistTokens.ts +2 -0
  16. package/openapi/gen/models/gistingTrainingConfigurationInput.ts +6 -2
  17. package/openapi/gen/models/gistingTrainingConfigurationInputBatchSize.ts +2 -0
  18. package/openapi/gen/models/gistingTrainingConfigurationInputGistTokens.ts +2 -0
  19. package/openapi/gen/models/index.ts +9 -4
  20. package/openapi/gen/models/sftTrainingConfiguration.ts +6 -3
  21. package/openapi/gen/models/sftTrainingConfigurationBatchSize.ts +2 -0
  22. package/openapi/gen/models/sftTrainingConfigurationInput.ts +6 -3
  23. package/openapi/gen/models/sftTrainingConfigurationInputBatchSize.ts +2 -0
  24. package/openapi/gen/models/sftTrainingConfigurationInputLoraRank.ts +2 -0
  25. package/openapi/gen/models/sftTrainingConfigurationLoraRank.ts +2 -0
  26. package/package.json +2 -2
  27. package/skills/catalog.json +11 -11
  28. package/skills/toloka-build/SKILL.md +29 -18
  29. package/skills/toloka-build/references/node-semantics.md +7 -7
  30. package/skills/toloka-build/references/workflow-and-cli.md +15 -12
  31. package/skills/toloka-fine-tuning/SKILL.md +145 -62
  32. package/skills/toloka-get-started/SKILL.md +37 -21
  33. package/skills/toloka-gisting/SKILL.md +156 -66
  34. package/skills/toloka-report-issue/references/report-template.md +1 -1
  35. package/src/cli/analytics.ts +50 -23
  36. package/src/cli/auth.ts +31 -12
  37. package/src/cli/auto-update.ts +12 -6
  38. package/src/cli/check.ts +76 -29
  39. package/src/cli/cli-entry.ts +3 -2
  40. package/src/cli/cli-error.ts +43 -0
  41. package/src/cli/commands/dataset.ts +13 -3
  42. package/src/cli/commands/models.ts +6 -7
  43. package/src/cli/commands/pipeline.ts +6 -7
  44. package/src/cli/commands/project.ts +9 -16
  45. package/src/cli/commands/report.ts +6 -2
  46. package/src/cli/commands/secret.ts +5 -4
  47. package/src/cli/commands/source.ts +4 -4
  48. package/src/cli/commands/status.ts +2 -3
  49. package/src/cli/commands/training.ts +50 -17
  50. package/src/cli/config.ts +2 -2
  51. package/src/cli/context.ts +55 -17
  52. package/src/cli/credentials.ts +12 -4
  53. package/src/cli/data.ts +96 -34
  54. package/src/cli/delivery-orders.ts +18 -1
  55. package/src/cli/file-system.ts +6 -2
  56. package/src/cli/format-error.ts +45 -2
  57. package/src/cli/init.ts +13 -7
  58. package/src/cli/models.ts +43 -12
  59. package/src/cli/program.ts +5 -56
  60. package/src/cli/project-context.ts +117 -32
  61. package/src/cli/project.ts +80 -28
  62. package/src/cli/report.ts +24 -9
  63. package/src/cli/runs.ts +33 -8
  64. package/src/cli/settings.ts +13 -5
  65. package/src/cli/skill.ts +61 -22
  66. package/src/cli/source.ts +3 -2
  67. package/src/cli/terminal-io.ts +13 -6
  68. package/src/cli/trainings.ts +129 -35
  69. package/src/cli/version.ts +2 -2
  70. package/src/client.ts +3 -9
  71. package/src/transport.ts +13 -1
  72. package/openapi/gen/models/gistingTrainingConfigurationInputRegime.ts +0 -2
  73. package/openapi/gen/models/gistingTrainingConfigurationRegime.ts +0 -2
  74. package/openapi/gen/models/sftTrainingConfigurationInputRegime.ts +0 -2
  75. package/openapi/gen/models/sftTrainingConfigurationRegime.ts +0 -2
@@ -1,4 +1,4 @@
1
- import { assert } from "es-toolkit";
1
+ import { PotatoApiError } from "../errors";
2
2
  import { readTolokaState, TOLOKA_STATE_FILENAME, writeTolokaState } from "../state";
3
3
  import type { TolokaState } from "../state";
4
4
  import type { AuthPrompt } from "./auth";
@@ -6,6 +6,8 @@ import type { TolokaCliFlags, TolokaEnvironment } from "./config";
6
6
  import { resolveInternalBaseUrl } from "./config";
7
7
  import { normalizeBaseUrl, requireStoredCredentials, writeStoredCredentials } from "./credentials";
8
8
  import type { CliEnvironment, StoredCredentials, StoredProjectAlias } from "./credentials";
9
+ import { apiErrorDetails } from "./format-error";
10
+ import { assertCli, TolokaCliError } from "./cli-error";
9
11
 
10
12
  type ProjectRecord = { readonly id: string; readonly name: string; readonly teamId: string };
11
13
 
@@ -24,20 +26,26 @@ export type ProjectGetApi = {
24
26
  };
25
27
  };
26
28
 
29
+ export type AccountTeam = { readonly id: string; readonly writable: boolean; readonly internal?: boolean };
30
+
27
31
  export type ProjectCreateApi = {
32
+ readonly me: () => Promise<{ readonly teams?: readonly AccountTeam[] }>;
28
33
  readonly projects: {
29
34
  readonly create: (input: {
30
35
  readonly teamId: string;
31
36
  readonly name?: string;
32
37
  readonly deliveryOrderId?: string;
33
38
  }) => Promise<ProjectRecord>;
34
- readonly listAll: (params?: object) => AsyncIterable<ProjectRecord>;
35
39
  };
36
40
  };
37
41
 
38
42
  export const MISSING_PROJECT_ID_MESSAGE =
39
43
  "Missing project ID. Pass --project-id, set TOLOKA_PROJECT_ID, use a bound toloka.json, run toloka project create for a new project, or run toloka project add for an existing project.";
40
44
 
45
+ export function assertProjectSelected(projectId: string | undefined): asserts projectId is string {
46
+ assertCli(projectId, MISSING_PROJECT_ID_MESSAGE, { hint: { disabled: true } });
47
+ }
48
+
41
49
  export const NO_PROJECT_SELECTED_MESSAGE =
42
50
  "No project selected. Run toloka project create for a new project, toloka project add for an existing project, or cd into a checkout.";
43
51
 
@@ -70,9 +78,10 @@ export type ProjectIo = {
70
78
 
71
79
  export const validateProjectAlias = (alias: string): string => {
72
80
  const trimmed = alias.trim();
73
- assert(
81
+ assertCli(
74
82
  trimmed.length > 0 && ALIAS_PATTERN.test(trimmed),
75
83
  "Project alias must be non-empty and contain no / or whitespace.",
84
+ { hint: { description: "Use letters, digits, dashes, or underscores, for example my-project." } },
76
85
  );
77
86
  return trimmed;
78
87
  };
@@ -183,11 +192,16 @@ const assertStoredNetworkMatches = (
183
192
  firstNonEmpty(flags.baseUrl) ??
184
193
  firstNonEmpty(environment.TOLOKA_BASE_URL) ??
185
194
  resolveInternalBaseUrl(environment);
186
- assert(flagOrEnvKey === undefined || flagOrEnvKey === stored.apiKey, NETWORK_MISMATCH_MESSAGE);
187
- assert(
195
+ assertCli(
196
+ flagOrEnvKey === undefined || flagOrEnvKey === stored.apiKey,
197
+ NETWORK_MISMATCH_MESSAGE,
198
+ { hint: { disabled: true } },
199
+ );
200
+ assertCli(
188
201
  flagOrEnvBase === undefined ||
189
202
  normalizeBaseUrl(flagOrEnvBase) === normalizeBaseUrl(stored.baseUrl),
190
203
  NETWORK_MISMATCH_MESSAGE,
204
+ { hint: { disabled: true } },
191
205
  );
192
206
  };
193
207
 
@@ -214,9 +228,9 @@ const promptOrFail = async (
214
228
  question: string,
215
229
  missing: string,
216
230
  ): Promise<string> => {
217
- assert(io?.prompt, missing);
231
+ assertCli(io?.prompt, missing, { hint: { disabled: true } });
218
232
  const value = (await io.prompt(question)).trim();
219
- assert(value.length > 0, missing);
233
+ assertCli(value.length > 0, missing, { hint: { disabled: true } });
220
234
  return value;
221
235
  };
222
236
 
@@ -254,27 +268,37 @@ export async function untrackProjectResource(options: {
254
268
  readonly io: ProjectIo;
255
269
  }): Promise<{ readonly output: string }> {
256
270
  const state = await readTolokaState(options.sourceRoot);
257
- assert(state, `No ${TOLOKA_STATE_FILENAME} found in ${options.sourceRoot}.`);
271
+ assertCli(
272
+ state,
273
+ `No ${TOLOKA_STATE_FILENAME} found in ${options.sourceRoot}.`,
274
+ { hint: { description: "Run toloka init in the source root, or cd into the checkout that holds toloka.json." } },
275
+ );
258
276
  const bindings = options.type === "pipeline" ? state.pipelines : state.datasets;
259
277
  const binding = bindings[options.name];
260
- assert(
278
+ assertCli(
261
279
  binding,
262
280
  `${options.type === "pipeline" ? "Pipeline" : "Dataset"} ${options.name} is not tracked.`,
281
+ { hint: { description: "Open toloka.json to see the tracked pipelines and datasets." } },
263
282
  );
264
283
  const id = "pipelineId" in binding ? binding.pipelineId : binding.datasetId;
265
284
  const label = `${options.type} ${options.name} (${id})`;
266
285
 
267
286
  if (!options.confirm) {
268
- assert(
287
+ assertCli(
269
288
  options.io.prompt,
270
289
  `Refusing to untrack ${label} without confirmation. Pass --confirm after reviewing ${TOLOKA_STATE_FILENAME}.`,
290
+ { hint: { disabled: true } },
271
291
  );
272
292
  const answer = (
273
293
  await options.io.prompt(`Untrack ${label}? The live resource will not be deleted. [y/N] `)
274
294
  )
275
295
  .trim()
276
296
  .toLowerCase();
277
- assert(answer === "y" || answer === "yes", "Untrack canceled.");
297
+ assertCli(
298
+ answer === "y" || answer === "yes",
299
+ "Untrack canceled.",
300
+ { hint: { disabled: true } },
301
+ );
278
302
  }
279
303
 
280
304
  const remaining = Object.fromEntries(
@@ -319,9 +343,10 @@ export async function addProject(options: {
319
343
 
320
344
  const existing = ownProject(found.credentials.projects, alias);
321
345
 
322
- assert(
346
+ assertCli(
323
347
  existing === undefined || existing.id === projectId,
324
348
  `Alias ${alias} already points at ${existing?.id}. Run toloka project remove ${alias} first.`,
349
+ { hint: { disabled: true } },
325
350
  );
326
351
 
327
352
  await options.afterPreflight?.();
@@ -346,7 +371,11 @@ export async function switchProject(options: {
346
371
  assertStoredNetworkMatches(options.flags ?? {}, options.environment, found.credentials);
347
372
  const alias = validateProjectAlias(options.alias);
348
373
  const project = ownProject(found.credentials.projects, alias);
349
- assert(project, `Unknown project alias ${alias}.`);
374
+ assertCli(
375
+ project,
376
+ `Unknown project alias ${alias}.`,
377
+ { hint: { description: "Run toloka project add <alias> <project-id> to register it, or check the alias spelling." } },
378
+ );
350
379
 
351
380
  await writeRegistry({ ...found.credentials, current: alias }, options.environment);
352
381
  const output = `Using project ${project.id} as ${alias}.`;
@@ -367,7 +396,11 @@ export async function removeProject(options: {
367
396
  string,
368
397
  StoredProjectAlias
369
398
  >;
370
- assert(ownProject(projects, alias), `Unknown project alias ${alias}.`);
399
+ assertCli(
400
+ ownProject(projects, alias),
401
+ `Unknown project alias ${alias}.`,
402
+ { hint: { description: "Run toloka project add <alias> <project-id> to register it, or check the alias spelling." } },
403
+ );
371
404
  delete projects[alias];
372
405
 
373
406
  const current = found.credentials.current === alias ? undefined : found.credentials.current;
@@ -401,25 +434,71 @@ export function currentProject(options: {
401
434
  return project === undefined ? { output } : { output, project };
402
435
  }
403
436
 
404
- const uniqueTeamId = (projects: ReadonlyArray<{ readonly teamId: string }>): string | undefined => {
405
- const teams = [...new Set(projects.map((project) => project.teamId))];
406
- return teams.length === 1 ? teams[0] : undefined;
407
- };
408
-
409
437
  const resolveTeamId = async (
410
438
  client: ProjectCreateApi,
411
439
  teamId: string | undefined,
412
440
  ): Promise<string> => {
413
441
  const explicit = firstNonEmpty(teamId);
414
442
  if (explicit !== undefined) return explicit;
415
- const visible: Array<{ readonly teamId: string }> = [];
416
- for await (const project of client.projects.listAll()) visible.push(project);
417
- const inferred = uniqueTeamId(visible);
418
- assert(
419
- inferred,
420
- "Missing team id. Pass --team-id. Visible projects do not share a single team, or none are visible.",
443
+ const { teams } = await client.me();
444
+ assertCli(
445
+ teams !== undefined,
446
+ "Missing team id. Pass --team-id. This platform does not report team memberships, so the team cannot be inferred.",
447
+ {
448
+ hint: {
449
+ description: "Copy the team id from the platform URL after /teams/, or update the platform.",
450
+ },
451
+ },
452
+ );
453
+ const writable = teams.filter((team) => team.writable).map((team) => team.id);
454
+ assertCli(
455
+ writable.length > 0,
456
+ "This account has no writable team, so it cannot create a project. Ask a team admin for write access or use toloka project add for an existing project.",
457
+ { hint: { disabled: true } },
421
458
  );
422
- return inferred;
459
+ const [single] = writable;
460
+ assertCli(
461
+ single !== undefined && writable.length === 1,
462
+ `Missing team id. Pass --team-id. This account can create projects in several teams: ${writable.join(", ")}.`,
463
+ { hint: { disabled: true } },
464
+ );
465
+ return single;
466
+ };
467
+
468
+ const DELIVERY_ORDER_HINT =
469
+ "Internal teams attach a Sunday delivery order to every project: run toloka delivery-order list, then re-run toloka project create with --delivery-order-id <id>.";
470
+
471
+ const DELIVERY_ORDER_NOT_ALLOWED_HINT =
472
+ "The owning team is not internal, so re-run toloka project create without --delivery-order-id. toloka whoami marks internal teams (internal) on its Teams: line.";
473
+
474
+ const deliveryOrderHint = (
475
+ input: Parameters<ProjectCreateApi["projects"]["create"]>[0],
476
+ details: string,
477
+ ): string | undefined => {
478
+ if (!details.includes("deliveryOrderId")) return undefined;
479
+ if (input.deliveryOrderId === undefined && details.includes("required")) return DELIVERY_ORDER_HINT;
480
+ if (input.deliveryOrderId !== undefined && details.includes("only be set")) {
481
+ return DELIVERY_ORDER_NOT_ALLOWED_HINT;
482
+ }
483
+ return undefined;
484
+ };
485
+
486
+ const createLiveProject = async (
487
+ client: ProjectCreateApi,
488
+ input: Parameters<ProjectCreateApi["projects"]["create"]>[0],
489
+ ): Promise<ProjectRecord> => {
490
+ try {
491
+ return await client.projects.create(input);
492
+ } catch (error) {
493
+ if (error instanceof PotatoApiError && error.status === 400) {
494
+ const details = apiErrorDetails(error);
495
+ const hint = details === undefined ? undefined : deliveryOrderHint(input, details);
496
+ if (hint !== undefined) {
497
+ throw new TolokaCliError(error.message, { cause: error, hint: { description: hint } });
498
+ }
499
+ }
500
+ throw error;
501
+ }
423
502
  };
424
503
 
425
504
  const bindCheckout = async (
@@ -437,12 +516,17 @@ const bindCheckout = async (
437
516
  });
438
517
  } catch (error) {
439
518
  const reason = error instanceof Error ? error.message : String(error);
440
- throw new Error(
519
+ throw new TolokaCliError(
441
520
  [
442
521
  `Created project ${created.id} as ${alias}.`,
443
522
  `Failed to write toloka.json: ${reason}`,
444
523
  ].join("\n"),
445
- { cause: error },
524
+ {
525
+ cause: error,
526
+ hint: {
527
+ description: `The project exists. Make ${sourceRoot} writable, then run toloka --project-id ${created.id} init to bind it.`,
528
+ },
529
+ },
446
530
  );
447
531
  }
448
532
  return undefined;
@@ -474,9 +558,10 @@ export async function createProject(options: {
474
558
  "Missing project alias. Pass an alias or run this in a terminal.",
475
559
  )),
476
560
  );
477
- assert(
561
+ assertCli(
478
562
  found.credentials.projects?.[alias] === undefined,
479
563
  `Alias ${alias} already exists. Run toloka project remove ${alias} first.`,
564
+ { hint: { disabled: true } },
480
565
  );
481
566
 
482
567
  const existingState =
@@ -486,7 +571,7 @@ export async function createProject(options: {
486
571
 
487
572
  const teamId = await resolveTeamId(options.client, options.teamId);
488
573
  const deliveryOrderId = firstNonEmpty(options.deliveryOrderId);
489
- const created = await options.client.projects.create({
574
+ const created = await createLiveProject(options.client, {
490
575
  teamId,
491
576
  name: firstNonEmpty(options.name) ?? alias,
492
577
  ...(deliveryOrderId === undefined ? {} : { deliveryOrderId }),
@@ -499,12 +584,12 @@ export async function createProject(options: {
499
584
  );
500
585
  } catch (error) {
501
586
  const reason = error instanceof Error ? error.message : String(error);
502
- throw new Error(
587
+ throw new TolokaCliError(
503
588
  [
504
589
  `Created ${created.id} but failed to update the local registry: ${reason}`,
505
590
  `Run: toloka project add ${alias} ${created.id}`,
506
591
  ].join("\n"),
507
- { cause: error },
592
+ { cause: error, hint: { disabled: true } },
508
593
  );
509
594
  }
510
595
 
@@ -1,4 +1,3 @@
1
- import { assert } from "es-toolkit";
2
1
  import { mkdir, rm, stat, writeFile } from "node:fs/promises";
3
2
  import { dirname, join, resolve } from "node:path";
4
3
  import type {
@@ -12,10 +11,11 @@ import type { Dataset, DatasetField, Pipeline, PotatoClient } from "../client";
12
11
  import { generatePipelineSourceTree } from "../importer";
13
12
  import { readTolokaState, TOLOKA_STATE_FILENAME, writeTolokaState } from "../state";
14
13
  import type { TolokaState } from "../state";
15
- import { MISSING_PROJECT_ID_MESSAGE } from "./project-context";
14
+ import { assertProjectSelected } from "./project-context";
16
15
  import { collectGraphSecretNames } from "./secrets";
17
16
  import { loadProjectSource } from "./source";
18
17
  import type { ProjectPipelineSource, ProjectSource } from "./source";
18
+ import { assertCli, REPORT_HINT } from "./cli-error";
19
19
 
20
20
  export type PipelinePlanChange = {
21
21
  readonly kind: "node" | "connection" | "field" | "layout";
@@ -296,9 +296,10 @@ export const desiredDatasets = (source: ProjectSource): readonly DesiredDataset[
296
296
  }
297
297
  for (const field of pipeline.definition.graph.fields) {
298
298
  const existing = fields.get(field.name);
299
- assert(
299
+ assertCli(
300
300
  existing === undefined || valueKey(existing.jsonSchema) === valueKey(field.jsonSchema),
301
301
  `Dataset "${pipeline.definition.dataset}" has conflicting schemas for field "${field.name}".`,
302
+ { hint: { description: "Give the field one JSON schema across all pipelines that share the dataset." } },
302
303
  );
303
304
  if (existing === undefined) fields.set(field.name, field);
304
305
  }
@@ -314,13 +315,15 @@ export const desiredDatasets = (source: ProjectSource): readonly DesiredDataset[
314
315
  const validateSource = (source: ProjectSource) => {
315
316
  const sourceDirectories = new Set<string>();
316
317
  for (const pipeline of source.pipelines) {
317
- assert(
318
+ assertCli(
318
319
  sourceDirectoryPattern.test(pipeline.sourceDirectory),
319
320
  `Invalid pipeline source directory ${pipeline.sourceDirectory}`,
321
+ { hint: { description: "Use a relative path without backslashes, // or . and .. segments." } },
320
322
  );
321
- assert(
323
+ assertCli(
322
324
  !sourceDirectories.has(pipeline.sourceDirectory),
323
325
  `Pipeline source directory ${pipeline.sourceDirectory} is declared more than once`,
326
+ { hint: { description: "Give each pipeline its own source directory." } },
324
327
  );
325
328
  sourceDirectories.add(pipeline.sourceDirectory);
326
329
  validatePipelineDefinition(pipeline.definition);
@@ -338,14 +341,19 @@ const liveDatasetFor = (
338
341
  ? undefined
339
342
  : datasets.find((dataset) => dataset.id === binding.datasetId);
340
343
  if (bound !== undefined) {
341
- assert(
344
+ assertCli(
342
345
  bound.name === name,
343
346
  `Dataset ${bound.id} is bound as "${name}" but is named "${bound.name}".`,
347
+ { hint: { description: "Rename the dataset in source to match the platform, or run toloka project untrack dataset <name> and bind it again with toloka apply." } },
344
348
  );
345
349
  return bound;
346
350
  }
347
351
  const matching = datasets.filter((dataset) => dataset.name === name);
348
- assert(matching.length <= 1, `Project has multiple datasets named "${name}".`);
352
+ assertCli(
353
+ matching.length <= 1,
354
+ `Project has multiple datasets named "${name}".`,
355
+ { hint: { description: "Rename the duplicate datasets in the platform, or reference the dataset by id." } },
356
+ );
349
357
  return matching[0];
350
358
  };
351
359
 
@@ -360,7 +368,11 @@ const livePipelineFor = (
360
368
  : pipelines.find((pipeline) => pipeline.id === binding.pipelineId);
361
369
  if (bound !== undefined) return bound;
362
370
  const matching = pipelines.filter((pipeline) => pipeline.name === source.name);
363
- assert(matching.length <= 1, `Project has multiple pipelines named "${source.name}".`);
371
+ assertCli(
372
+ matching.length <= 1,
373
+ `Project has multiple pipelines named "${source.name}".`,
374
+ { hint: { description: "Rename the duplicate pipelines in the platform, then re-run the command." } },
375
+ );
364
376
  return matching[0];
365
377
  };
366
378
 
@@ -542,7 +554,7 @@ const inspectProject = async (
542
554
  validateSource(source);
543
555
  const state = await readTolokaState(options.sourceRoot);
544
556
  const projectId = options.projectId ?? state?.projectId;
545
- assert(projectId, MISSING_PROJECT_ID_MESSAGE);
557
+ assertProjectSelected(projectId);
546
558
 
547
559
  const bindings = state?.projectId === projectId ? state : undefined;
548
560
  const requiredSecrets = new Set(
@@ -627,7 +639,11 @@ export async function importProject(
627
639
  const datasetIds = new Set<string>();
628
640
  for (const snapshot of snapshots) {
629
641
  const rootNode = snapshot.graph.nodes.find((node) => node.type === "root");
630
- assert(rootNode?.settings.datasetId, "Every imported pipeline must have a bound dataset.");
642
+ assertCli(
643
+ rootNode?.settings.datasetId,
644
+ "Every imported pipeline must have a bound dataset.",
645
+ { hint: { description: "Bind a dataset to the pipeline in the platform, then re-run toloka import." } },
646
+ );
631
647
  datasetIds.add(rootNode.settings.datasetId);
632
648
  }
633
649
  const projectDatasets = await collectAsync(
@@ -636,7 +652,11 @@ export async function importProject(
636
652
  const datasets: Dataset[] = [];
637
653
  for (const datasetId of [...datasetIds].toSorted()) {
638
654
  const dataset = projectDatasets.find((candidate) => candidate.id === datasetId);
639
- assert(dataset, `Project ${options.projectId} has no dataset ${datasetId}.`);
655
+ assertCli(
656
+ dataset,
657
+ `Project ${options.projectId} has no dataset ${datasetId}.`,
658
+ { hint: { description: "Check toloka dataset list; the pipeline's dataset binding must point at a dataset of this project." } },
659
+ );
640
660
  datasets.push(dataset);
641
661
  }
642
662
  const datasetsById = new Map<string, Dataset>(datasets.map((dataset) => [dataset.id, dataset]));
@@ -646,22 +666,32 @@ export async function importProject(
646
666
  const stateDatasets: Record<string, { readonly datasetId: string }> = {};
647
667
  for (const [index, pipeline] of pipelines.entries()) {
648
668
  const snapshot = snapshots[index];
649
- assert(snapshot, `Pipeline ${pipeline.id} has no graph snapshot.`);
669
+ assertCli(snapshot, `Pipeline ${pipeline.id} has no graph snapshot.`, { hint: REPORT_HINT });
650
670
  const rootNode = snapshot.graph.nodes.find((node) => node.type === "root");
651
- assert(rootNode?.settings.datasetId, `Pipeline ${pipeline.id} has no dataset binding.`);
671
+ assertCli(
672
+ rootNode?.settings.datasetId,
673
+ `Pipeline ${pipeline.id} has no dataset binding.`,
674
+ { hint: { description: "Bind a dataset to the pipeline in the platform, then re-run toloka import." } },
675
+ );
652
676
  const dataset = datasetsById.get(rootNode.settings.datasetId);
653
- assert(
677
+ assertCli(
654
678
  dataset,
655
679
  `Pipeline ${pipeline.id} references unknown dataset ${rootNode.settings.datasetId}.`,
680
+ { hint: { description: "Check toloka dataset list; the pipeline's dataset binding must point at a dataset of this project." } },
656
681
  );
657
682
  const existingDatasetBinding = stateDatasets[dataset.name];
658
- assert(
683
+ assertCli(
659
684
  existingDatasetBinding === undefined || existingDatasetBinding.datasetId === dataset.id,
660
685
  `Project has multiple referenced datasets named "${dataset.name}".`,
686
+ { hint: { description: "Rename the duplicate datasets in the platform, then re-run toloka import." } },
661
687
  );
662
688
  stateDatasets[dataset.name] = { datasetId: dataset.id };
663
689
  const sourceDirectory = directories.get(pipeline.id);
664
- assert(sourceDirectory, `Pipeline ${pipeline.id} has no source directory.`);
690
+ assertCli(
691
+ sourceDirectory,
692
+ `Pipeline ${pipeline.id} has no source directory.`,
693
+ { hint: REPORT_HINT },
694
+ );
665
695
  statePipelines[sourceDirectory] = { pipelineId: pipeline.id };
666
696
  for (const [relativePath, source] of Object.entries(
667
697
  generatePipelineSourceTree(snapshot.graph, { dataset: dataset.name }),
@@ -678,9 +708,10 @@ export async function importProject(
678
708
  } catch (error) {
679
709
  if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
680
710
  }
681
- assert(
711
+ assertCli(
682
712
  !pipelinesRootExists || options.force === true,
683
713
  `Folder already exists: ${pipelinesRoot}. Pass --force to replace it.`,
714
+ { hint: { disabled: true } },
684
715
  );
685
716
  if (pipelinesRootExists) await rm(pipelinesRoot, { recursive: true, force: true });
686
717
 
@@ -725,7 +756,7 @@ export async function projectStatus(
725
756
  options: PlanProjectOptions,
726
757
  ): Promise<ProjectStatusResult> {
727
758
  const projectId = options.projectId;
728
- assert(projectId, MISSING_PROJECT_ID_MESSAGE);
759
+ assertProjectSelected(projectId);
729
760
  const live = await client.projects.get({ projectId });
730
761
  const inspection = await inspectProject(client, { ...options, listAllSecrets: true });
731
762
  const pending = inspection.changes.length;
@@ -751,24 +782,28 @@ export async function applyProject(
751
782
  options: ApplyProjectOptions,
752
783
  ): Promise<ApplyProjectResult> {
753
784
  const inspection = await inspectProject(client, options);
754
- assert(
785
+ assertCli(
755
786
  inspection.removedPipelines.length === 0,
756
787
  `Refusing to delete pipelines absent from source: ${inspection.removedPipelines.join(", ")}.`,
788
+ { hint: { description: "Restore the pipeline in source, or run toloka project untrack pipeline <name> to stop managing it." } },
757
789
  );
758
- assert(
790
+ assertCli(
759
791
  inspection.removedDatasets.length === 0,
760
792
  `Refusing to delete datasets absent from source: ${inspection.removedDatasets.join(", ")}. Dataset items are never deleted.`,
793
+ { hint: { description: "Restore the dataset in source, or run toloka project untrack dataset <name> to stop managing it." } },
761
794
  );
762
- assert(
795
+ assertCli(
763
796
  inspection.missingSecrets.length === 0,
764
797
  `Required secrets are missing: ${inspection.missingSecrets.join(", ")}. Run toloka secret set <name> before apply.`,
798
+ { hint: { disabled: true } },
765
799
  );
766
800
  const conflictingFields = inspection.datasets.flatMap((dataset) =>
767
801
  dataset.conflictingFields.map((field) => `${dataset.name}.${field.name}`),
768
802
  );
769
- assert(
803
+ assertCli(
770
804
  conflictingFields.length === 0,
771
805
  `Dataset field schemas conflict with source: ${conflictingFields.join(", ")}. Existing fields are never rewritten.`,
806
+ { hint: { description: "Add the changed columns under new field names, or create a new dataset for the new schema." } },
772
807
  );
773
808
 
774
809
  let writes = 0;
@@ -798,9 +833,10 @@ export async function applyProject(
798
833
  const pipelineIds = new Map<string, string>();
799
834
  for (const pipelineInspection of inspection.pipelines) {
800
835
  const datasetId = datasetIds.get(pipelineInspection.source.definition.dataset);
801
- assert(
836
+ assertCli(
802
837
  datasetId,
803
838
  `Dataset ${pipelineInspection.source.definition.dataset} has no resolved ID.`,
839
+ { hint: REPORT_HINT },
804
840
  );
805
841
  const desiredGraph = resolvePipelineDefinition(pipelineInspection.source.definition, {
806
842
  datasetId,
@@ -819,7 +855,11 @@ export async function applyProject(
819
855
  pipelineIds.set(pipelineInspection.source.sourceDirectory, pipelineInspection.pipeline.id);
820
856
  if (pipelineInspection.graphChanges.length === 0) continue;
821
857
  const snapshot = pipelineInspection.snapshot;
822
- assert(snapshot, `Pipeline ${pipelineInspection.pipeline.id} has no graph snapshot.`);
858
+ assertCli(
859
+ snapshot,
860
+ `Pipeline ${pipelineInspection.pipeline.id} has no graph snapshot.`,
861
+ { hint: REPORT_HINT },
862
+ );
823
863
  await client.pipelines.replaceGraph({
824
864
  pipelineId: pipelineInspection.pipeline.id,
825
865
  graph: graphWithLiveState(desiredGraph, snapshot.graph),
@@ -833,14 +873,18 @@ export async function applyProject(
833
873
  pipelines: Object.fromEntries(
834
874
  inspection.source.pipelines.map((pipeline) => {
835
875
  const pipelineId = pipelineIds.get(pipeline.sourceDirectory);
836
- assert(pipelineId, `Pipeline ${pipeline.sourceDirectory} has no resolved ID.`);
876
+ assertCli(
877
+ pipelineId,
878
+ `Pipeline ${pipeline.sourceDirectory} has no resolved ID.`,
879
+ { hint: REPORT_HINT },
880
+ );
837
881
  return [pipeline.sourceDirectory, { pipelineId }];
838
882
  }),
839
883
  ),
840
884
  datasets: Object.fromEntries(
841
885
  inspection.datasets.map((dataset) => {
842
886
  const datasetId = datasetIds.get(dataset.name);
843
- assert(datasetId, `Dataset ${dataset.name} has no resolved ID.`);
887
+ assertCli(datasetId, `Dataset ${dataset.name} has no resolved ID.`, { hint: REPORT_HINT });
844
888
  return [dataset.name, { datasetId }];
845
889
  }),
846
890
  ),
@@ -957,8 +1001,16 @@ export async function validateProject(
957
1001
  const lines = results.flatMap((result) => {
958
1002
  if (result.skipped !== undefined)
959
1003
  return [`- ${result.sourceDirectory}: skipped (${result.skipped})`];
960
- assert(result.report, `Pipeline ${result.sourceDirectory} is missing a validation report`);
961
- assert(result.status, `Pipeline ${result.sourceDirectory} is missing a validation status`);
1004
+ assertCli(
1005
+ result.report,
1006
+ `Pipeline ${result.sourceDirectory} is missing a validation report`,
1007
+ { hint: REPORT_HINT },
1008
+ );
1009
+ assertCli(
1010
+ result.status,
1011
+ `Pipeline ${result.sourceDirectory} is missing a validation status`,
1012
+ { hint: REPORT_HINT },
1013
+ );
962
1014
  const header = `- ${result.sourceDirectory}: ${result.status.replace("_", "-")}`;
963
1015
  return [header, ...formatValidationIssues(result.report)];
964
1016
  });
package/src/cli/report.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { assert } from "es-toolkit";
2
1
  import { randomUUID } from "node:crypto";
3
2
  import { constants } from "node:fs";
4
3
  import { lstat, open, type FileHandle } from "node:fs/promises";
5
4
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
5
  import type { CliAnalytics } from "./analytics";
6
+ import { assertCli, TolokaCliError } from "./cli-error";
7
7
 
8
8
  export type IssueReportAuthor = "human" | "agent";
9
9
 
@@ -31,9 +31,10 @@ const assertNoSymlinkParents = async (absolutePath: string, sourceRoot: string):
31
31
  for (let index = 1; index < segments.length; index += 1) {
32
32
  const directory = join(sourceRoot, ...segments.slice(0, index));
33
33
  const stats = await lstat(directory);
34
- assert(
34
+ assertCli(
35
35
  !stats.isSymbolicLink(),
36
36
  `Report file ${absolutePath} is inside the symbolic link ${directory}; select a path that does not traverse symbolic links.`,
37
+ { hint: { disabled: true } },
37
38
  );
38
39
  }
39
40
  };
@@ -49,20 +50,30 @@ const openReportFile = async (
49
50
  try {
50
51
  handle = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
51
52
  } catch (error) {
52
- assert(
53
+ assertCli(
53
54
  !(error instanceof Error && "code" in error && error.code === "ELOOP"),
54
55
  `Report file ${absolutePath} is a symbolic link; select the file it points to directly.`,
56
+ { hint: { disabled: true } },
55
57
  );
56
58
  throw error;
57
59
  }
58
60
 
59
61
  try {
60
62
  const stats = await handle.stat();
61
- assert(stats.isFile(), `Report file ${absolutePath} is not a regular file.`);
62
- assert(stats.size > 0, `Report file ${absolutePath} is empty.`);
63
- assert(
63
+ assertCli(
64
+ stats.isFile(),
65
+ `Report file ${absolutePath} is not a regular file.`,
66
+ { hint: { description: "Pass the path of a Markdown or text file." } },
67
+ );
68
+ assertCli(
69
+ stats.size > 0,
70
+ `Report file ${absolutePath} is empty.`,
71
+ { hint: { description: "Write the report first; the toloka-report-issue skill describes the format." } },
72
+ );
73
+ assertCli(
64
74
  stats.size <= ISSUE_REPORT_MAX_BYTES,
65
75
  `Report file ${absolutePath} is ${stats.size} bytes; the limit is 256 KiB.`,
76
+ { hint: { description: "Trim the report to under 256 KiB, for example by shortening logs." } },
66
77
  );
67
78
  return { handle, absolutePath, size: stats.size };
68
79
  } catch (error) {
@@ -75,14 +86,17 @@ const readReportText = async (path: string, sourceRoot: string, cwd: string): Pr
75
86
  const opened = await openReportFile(path, sourceRoot, cwd);
76
87
  try {
77
88
  const bytes = await opened.handle.readFile();
78
- assert(
89
+ assertCli(
79
90
  bytes.byteLength === opened.size,
80
91
  `${opened.absolutePath} changed while it was being read.`,
92
+ { hint: { description: "Re-run toloka report once the file is no longer being written." } },
81
93
  );
82
94
  try {
83
95
  return REPORT_DECODER.decode(bytes);
84
96
  } catch {
85
- throw new Error(`Report file ${opened.absolutePath} is not valid UTF-8.`);
97
+ throw new TolokaCliError(`Report file ${opened.absolutePath} is not valid UTF-8.`, {
98
+ hint: { description: "Save the report as UTF-8 text." },
99
+ });
86
100
  }
87
101
  } finally {
88
102
  await opened.handle.close();
@@ -107,9 +121,10 @@ export async function submitIssueReport(options: {
107
121
  readonly io: IssueReportIo;
108
122
  }): Promise<number> {
109
123
  const { analytics, io } = options;
110
- assert(
124
+ assertCli(
111
125
  analytics.issueReportingEnabled,
112
126
  "Issue reporting is disabled. Re-run with TOLOKA_TELEMETRY=1.",
127
+ { hint: { disabled: true } },
113
128
  );
114
129
  const reportText = await readReportText(options.path, options.sourceRoot, options.cwd);
115
130
  io.stdout("Issue report preview:");