@toloka-platform/sdk 0.11.1 → 0.11.3

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 (89) hide show
  1. package/README.md +4 -2
  2. package/dist/cli/toloka.js +167 -160
  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/authoring.md +15 -5
  7. package/docs/cli.md +48 -16
  8. package/docs/getting-started.md +1 -1
  9. package/docs/limitations.md +2 -2
  10. package/docs/testing.md +5 -3
  11. package/docs/troubleshooting.md +1 -1
  12. package/examples/README.md +11 -0
  13. package/examples/app-crowdtesting/README.md +41 -0
  14. package/examples/app-crowdtesting/fields.ts +6 -0
  15. package/examples/app-crowdtesting/nodes/app-flow-test/node.ts +381 -0
  16. package/examples/app-crowdtesting/nodes/collection/node.ts +8 -0
  17. package/examples/app-crowdtesting/pipeline.browser.integration.spec.ts +77 -0
  18. package/examples/app-crowdtesting/pipeline.ts +17 -0
  19. package/examples/egocentric-video-collection/README.md +35 -0
  20. package/examples/egocentric-video-collection/fields.ts +24 -0
  21. package/examples/egocentric-video-collection/nodes/collection/node.ts +5 -0
  22. package/examples/egocentric-video-collection/nodes/record-activity/node.ts +760 -0
  23. package/examples/egocentric-video-collection/pipeline.browser.integration.spec.ts +65 -0
  24. package/examples/egocentric-video-collection/pipeline.ts +17 -0
  25. package/examples/product-moderation/README.md +49 -0
  26. package/examples/product-moderation/fields.ts +37 -0
  27. package/examples/product-moderation/nodes/collection/node.ts +16 -0
  28. package/examples/product-moderation/nodes/moderate-product/instruction.ts +77 -0
  29. package/examples/product-moderation/nodes/moderate-product/node.ts +368 -0
  30. package/examples/product-moderation/pipeline.browser.integration.spec.ts +48 -0
  31. package/examples/product-moderation/pipeline.ts +33 -0
  32. package/examples/video-pair-rating/fields.ts +16 -4
  33. package/examples/video-pair-rating/nodes/adjudicate/node.ts +78 -3
  34. package/examples/video-pair-rating/nodes/adjudicate_op/node.ts +28 -3
  35. package/examples/video-pair-rating/nodes/overall_preference_pick/node.ts +28 -3
  36. package/examples/video-pair-rating/nodes/rate_pair/node.ts +65 -2
  37. package/openapi/gen/api.ts +22 -0
  38. package/openapi/gen/models/generationBudget.ts +8 -0
  39. package/openapi/gen/models/generationBudgetReasoningEffort.ts +2 -0
  40. package/openapi/gen/models/generationModel.ts +12 -0
  41. package/openapi/gen/models/generationModelDefaultReasoningEffort.ts +2 -0
  42. package/openapi/gen/models/generationModelId.ts +2 -0
  43. package/openapi/gen/models/index.ts +8 -0
  44. package/openapi/gen/models/invalidFieldSchema.ts +6 -0
  45. package/openapi/gen/models/listGenerationModels200.ts +7 -0
  46. package/openapi/gen/models/listGenerationModels200DefaultGenerationModelId.ts +2 -0
  47. package/openapi/gen/models/pipelineGraphSnapshot.ts +2 -0
  48. package/package.json +2 -2
  49. package/skills/catalog.json +16 -12
  50. package/skills/toloka-build/SKILL.md +44 -56
  51. package/skills/toloka-build/references/human-work.md +156 -0
  52. package/skills/toloka-build/references/local-setup.md +136 -0
  53. package/skills/toloka-build/references/node-checks.md +11 -0
  54. package/skills/toloka-build/references/node-semantics.md +22 -213
  55. package/skills/toloka-build/references/task-ui.md +61 -0
  56. package/skills/toloka-build/references/workflow-and-cli.md +29 -227
  57. package/skills/toloka-fine-tuning/SKILL.md +1 -1
  58. package/skills/toloka-get-started/SKILL.md +1 -1
  59. package/skills/toloka-gisting/SKILL.md +1 -1
  60. package/src/authoring/index.ts +26 -7
  61. package/src/checks/task-ui-typecheck.ts +3 -3
  62. package/src/cli/analytics.ts +1 -0
  63. package/src/cli/auth.ts +51 -18
  64. package/src/cli/check.ts +88 -17
  65. package/src/cli/commands/auth.ts +5 -3
  66. package/src/cli/commands/generation-model.ts +18 -0
  67. package/src/cli/commands/pipeline.ts +1 -1
  68. package/src/cli/commands/project.ts +7 -2
  69. package/src/cli/commands/source.ts +14 -3
  70. package/src/cli/commands/ui.ts +23 -0
  71. package/src/cli/config.ts +15 -9
  72. package/src/cli/context.ts +30 -12
  73. package/src/cli/credentials.ts +2 -2
  74. package/src/cli/environment.ts +5 -1
  75. package/src/cli/generation-models.ts +46 -0
  76. package/src/cli/program.ts +7 -0
  77. package/src/cli/project-context.ts +5 -2
  78. package/src/cli/project.ts +288 -138
  79. package/src/cli/trainings.ts +12 -0
  80. package/src/cli/ui.ts +63 -0
  81. package/src/client.ts +5 -0
  82. package/src/core-entity-types.json +32 -0
  83. package/src/graph-rules.json +11 -0
  84. package/src/importer.ts +46 -40
  85. package/src/index.ts +4 -0
  86. package/src/testing/task-ui-browser.ts +45 -2
  87. package/src/testing/task-ui.ts +2 -9
  88. package/src/transport.ts +28 -6
  89. package/src/validate.ts +232 -2
@@ -10,6 +10,7 @@ import { resolvePipelineDefinition, validatePipelineDefinition } from "../author
10
10
  import type { Dataset, DatasetField, Pipeline, PotatoClient } from "../client";
11
11
  import { generatePipelineSourceTree } from "../importer";
12
12
  import { readTolokaState, TOLOKA_STATE_FILENAME, writeTolokaState } from "../state";
13
+ import { platformRuleIssues } from "../validate";
13
14
  import type { TolokaState } from "../state";
14
15
  import { assertProjectSelected } from "./project-context";
15
16
  import { collectGraphSecretNames } from "./secrets";
@@ -82,9 +83,11 @@ export type ApplyProjectResult = {
82
83
  readonly output: string;
83
84
  };
84
85
 
85
- export type ValidateProjectOptions = PlanProjectOptions;
86
+ export type ValidateProjectOptions = PlanProjectOptions & {
87
+ readonly wait?: { readonly timeoutMs: number; readonly pollIntervalMs: number };
88
+ };
86
89
 
87
- export type PipelineValidationStatus = "valid" | "invalid" | "checks_pending";
90
+ export type PipelineValidationStatus = "valid" | "invalid" | "checks_pending" | "checks_running";
88
91
 
89
92
  export type PipelineValidationResult = {
90
93
  readonly sourceDirectory: string;
@@ -92,6 +95,7 @@ export type PipelineValidationResult = {
92
95
  readonly report?: PipelineValidationReport;
93
96
  readonly status?: PipelineValidationStatus;
94
97
  readonly skipped?: string;
98
+ readonly note?: string;
95
99
  };
96
100
 
97
101
  export type ValidateProjectResult = {
@@ -261,6 +265,13 @@ export function diffPipelineGraphs(
261
265
  ];
262
266
  }
263
267
 
268
+ type CheckedNode = Extract<PipelineGraphV1["nodes"][number], { type: "code" | "generation" | "quorum" }>;
269
+
270
+ const withLiveChecks = <Node extends CheckedNode>(authored: Node, live: Node): Node =>
271
+ live.settings.checks === undefined
272
+ ? authored
273
+ : { ...authored, settings: { ...authored.settings, checks: { ...live.settings.checks, ...authored.settings.checks } } };
274
+
264
275
  const graphWithLiveState = (
265
276
  authoredGraph: PipelineGraphV1,
266
277
  liveGraph: PipelineGraphV1,
@@ -277,28 +288,43 @@ const graphWithLiveState = (
277
288
  ? {}
278
289
  : { itemLimit: liveRoot.settings.itemLimit }),
279
290
  };
291
+ const liveNodes = new Map(liveGraph.nodes.map((node) => [node.id, node]));
280
292
  return {
281
293
  ...authoredGraph,
282
294
  ...(liveGraph.layout === undefined ? {} : { layout: liveGraph.layout }),
283
- nodes: authoredGraph.nodes.map((node) =>
284
- node.type === "root" ? { ...node, settings: { ...liveRunScope, ...node.settings } } : node,
285
- ),
295
+ nodes: authoredGraph.nodes.map((node) => {
296
+ const live = liveNodes.get(node.id);
297
+ switch (node.type) {
298
+ case "root":
299
+ return { ...node, settings: { ...liveRunScope, ...node.settings } };
300
+ case "code":
301
+ return live?.type === "code" ? withLiveChecks(node, live) : node;
302
+ case "generation":
303
+ return live?.type === "generation" ? withLiveChecks(node, live) : node;
304
+ case "quorum":
305
+ return live?.type === "quorum" ? withLiveChecks(node, live) : node;
306
+ case "match":
307
+ return node;
308
+ }
309
+ }),
286
310
  };
287
311
  };
288
312
 
289
313
  export const desiredDatasets = (source: ProjectSource): readonly DesiredDataset[] => {
290
314
  const fieldsByDataset = new Map<string, Map<string, PipelineGraphV1["fields"][number]>>();
291
315
  for (const pipeline of source.pipelines) {
292
- let fields = fieldsByDataset.get(pipeline.definition.dataset);
316
+ const datasetName = pipeline.definition.dataset;
317
+ if (datasetName === undefined) continue;
318
+ let fields = fieldsByDataset.get(datasetName);
293
319
  if (fields === undefined) {
294
320
  fields = new Map();
295
- fieldsByDataset.set(pipeline.definition.dataset, fields);
321
+ fieldsByDataset.set(datasetName, fields);
296
322
  }
297
323
  for (const field of pipeline.definition.graph.fields) {
298
324
  const existing = fields.get(field.name);
299
325
  assertCli(
300
326
  existing === undefined || valueKey(existing.jsonSchema) === valueKey(field.jsonSchema),
301
- `Dataset "${pipeline.definition.dataset}" has conflicting schemas for field "${field.name}".`,
327
+ `Dataset "${datasetName}" has conflicting schemas for field "${field.name}".`,
302
328
  { hint: { description: "Give the field one JSON schema across all pipelines that share the dataset." } },
303
329
  );
304
330
  if (existing === undefined) fields.set(field.name, field);
@@ -452,6 +478,29 @@ const inspectDatasets = async (
452
478
  });
453
479
  };
454
480
 
481
+ const pipelineGraphChanges = (
482
+ pipelineSource: ProjectPipelineSource,
483
+ snapshot: PipelineGraphSnapshot | undefined,
484
+ datasets: readonly DatasetInspection[],
485
+ ): readonly PipelinePlanChange[] => {
486
+ if (snapshot === undefined) return [];
487
+ const datasetName = pipelineSource.definition.dataset;
488
+ const liveDatasetId = snapshot.graph.nodes.find((node) => node.type === "root")?.settings.datasetId;
489
+ if (datasetName === undefined && liveDatasetId !== undefined)
490
+ return [{ kind: "node", action: "removed", label: "root dataset binding", id: "root" }];
491
+ const dataset =
492
+ datasetName === undefined
493
+ ? undefined
494
+ : datasets.find((entry) => entry.name === datasetName)?.dataset;
495
+ if (datasetName !== undefined && dataset === undefined)
496
+ return [{ kind: "node", action: "changed", label: "root dataset binding", id: "root" }];
497
+ const desiredGraph = resolvePipelineDefinition(
498
+ pipelineSource.definition,
499
+ dataset === undefined ? {} : { datasetId: dataset.id },
500
+ );
501
+ return diffPipelineGraphs(graphWithLiveState(desiredGraph, snapshot.graph), snapshot.graph);
502
+ };
503
+
455
504
  const inspectPipelines = async (
456
505
  client: PotatoClient,
457
506
  source: ProjectSource,
@@ -474,27 +523,11 @@ const inspectPipelines = async (
474
523
  );
475
524
  return pipelinePairs.map(({ source: pipelineSource, pipeline }, index) => {
476
525
  const snapshot = snapshots[index];
477
- const dataset = datasets.find(
478
- (entry) => entry.name === pipelineSource.definition.dataset,
479
- )?.dataset;
480
- let graphChanges: readonly PipelinePlanChange[] = [];
481
- if (pipeline !== undefined && snapshot !== undefined) {
482
- graphChanges =
483
- dataset === undefined
484
- ? [{ kind: "node", action: "changed", label: "root dataset binding", id: "root" }]
485
- : diffPipelineGraphs(
486
- graphWithLiveState(
487
- resolvePipelineDefinition(pipelineSource.definition, { datasetId: dataset.id }),
488
- snapshot.graph,
489
- ),
490
- snapshot.graph,
491
- );
492
- }
493
526
  return {
494
527
  source: pipelineSource,
495
528
  ...(pipeline === undefined ? {} : { pipeline }),
496
529
  ...(snapshot === undefined ? {} : { snapshot }),
497
- graphChanges,
530
+ graphChanges: pipelineGraphChanges(pipelineSource, snapshot, datasets),
498
531
  };
499
532
  });
500
533
  };
@@ -638,13 +671,8 @@ export async function importProject(
638
671
  );
639
672
  const datasetIds = new Set<string>();
640
673
  for (const snapshot of snapshots) {
641
- const rootNode = snapshot.graph.nodes.find((node) => node.type === "root");
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
- );
647
- datasetIds.add(rootNode.settings.datasetId);
674
+ const datasetId = snapshot.graph.nodes.find((node) => node.type === "root")?.settings.datasetId;
675
+ if (datasetId !== undefined) datasetIds.add(datasetId);
648
676
  }
649
677
  const projectDatasets = await collectAsync(
650
678
  client.datasets.listAll({ projectId: options.projectId }),
@@ -667,25 +695,22 @@ export async function importProject(
667
695
  for (const [index, pipeline] of pipelines.entries()) {
668
696
  const snapshot = snapshots[index];
669
697
  assertCli(snapshot, `Pipeline ${pipeline.id} has no graph snapshot.`, { hint: REPORT_HINT });
670
- const rootNode = snapshot.graph.nodes.find((node) => node.type === "root");
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
- );
676
- const dataset = datasetsById.get(rootNode.settings.datasetId);
698
+ const datasetId = snapshot.graph.nodes.find((node) => node.type === "root")?.settings.datasetId;
699
+ const dataset = datasetId === undefined ? undefined : datasetsById.get(datasetId);
677
700
  assertCli(
678
- dataset,
679
- `Pipeline ${pipeline.id} references unknown dataset ${rootNode.settings.datasetId}.`,
701
+ datasetId === undefined || dataset !== undefined,
702
+ `Pipeline ${pipeline.id} references unknown dataset ${datasetId}.`,
680
703
  { hint: { description: "Check toloka dataset list; the pipeline's dataset binding must point at a dataset of this project." } },
681
704
  );
682
- const existingDatasetBinding = stateDatasets[dataset.name];
683
- assertCli(
684
- existingDatasetBinding === undefined || existingDatasetBinding.datasetId === dataset.id,
685
- `Project has multiple referenced datasets named "${dataset.name}".`,
686
- { hint: { description: "Rename the duplicate datasets in the platform, then re-run toloka import." } },
687
- );
688
- stateDatasets[dataset.name] = { datasetId: dataset.id };
705
+ if (dataset !== undefined) {
706
+ const existingDatasetBinding = stateDatasets[dataset.name];
707
+ assertCli(
708
+ existingDatasetBinding === undefined || existingDatasetBinding.datasetId === dataset.id,
709
+ `Project has multiple referenced datasets named "${dataset.name}".`,
710
+ { hint: { description: "Rename the duplicate datasets in the platform, then re-run toloka import." } },
711
+ );
712
+ stateDatasets[dataset.name] = { datasetId: dataset.id };
713
+ }
689
714
  const sourceDirectory = directories.get(pipeline.id);
690
715
  assertCli(
691
716
  sourceDirectory,
@@ -694,7 +719,10 @@ export async function importProject(
694
719
  );
695
720
  statePipelines[sourceDirectory] = { pipelineId: pipeline.id };
696
721
  for (const [relativePath, source] of Object.entries(
697
- generatePipelineSourceTree(snapshot.graph, { dataset: dataset.name }),
722
+ generatePipelineSourceTree(snapshot.graph, {
723
+ ...(dataset === undefined ? {} : { dataset: dataset.name }),
724
+ invalidFieldSchemas: snapshot.invalidFieldSchemas,
725
+ }),
698
726
  )) {
699
727
  tree[`${sourceDirectory}/${relativePath}`] = source;
700
728
  }
@@ -777,6 +805,45 @@ export async function projectStatus(
777
805
  };
778
806
  }
779
807
 
808
+ const applyPipeline = async (
809
+ client: PotatoClient,
810
+ projectId: string,
811
+ pipelineInspection: PipelineInspection,
812
+ datasetIds: ReadonlyMap<string, string>,
813
+ ): Promise<{ readonly pipelineId: string; readonly writes: number }> => {
814
+ const datasetName = pipelineInspection.source.definition.dataset;
815
+ const datasetId = datasetName === undefined ? undefined : datasetIds.get(datasetName);
816
+ assertCli(
817
+ datasetName === undefined || datasetId !== undefined,
818
+ `Dataset ${datasetName} has no resolved ID.`,
819
+ { hint: REPORT_HINT },
820
+ );
821
+ const desiredGraph = resolvePipelineDefinition(
822
+ pipelineInspection.source.definition,
823
+ datasetId === undefined ? {} : { datasetId },
824
+ );
825
+ if (pipelineInspection.pipeline === undefined) {
826
+ const pipeline = await client.pipelines.import({
827
+ projectId,
828
+ name: pipelineInspection.source.name,
829
+ graph: desiredGraph,
830
+ });
831
+ if (datasetId === undefined) return { pipelineId: pipeline.id, writes: 1 };
832
+ await client.pipelines.attachDataset({ pipelineId: pipeline.id, datasetId });
833
+ return { pipelineId: pipeline.id, writes: 2 };
834
+ }
835
+ const pipelineId = pipelineInspection.pipeline.id;
836
+ if (pipelineInspection.graphChanges.length === 0) return { pipelineId, writes: 0 };
837
+ const snapshot = pipelineInspection.snapshot;
838
+ assertCli(snapshot, `Pipeline ${pipelineId} has no graph snapshot.`, { hint: REPORT_HINT });
839
+ await client.pipelines.replaceGraph({
840
+ pipelineId,
841
+ graph: graphWithLiveState(desiredGraph, snapshot.graph),
842
+ expectedVersion: snapshot.version,
843
+ });
844
+ return { pipelineId, writes: 1 };
845
+ };
846
+
780
847
  export async function applyProject(
781
848
  client: PotatoClient,
782
849
  options: ApplyProjectOptions,
@@ -787,6 +854,19 @@ export async function applyProject(
787
854
  `Refusing to delete pipelines absent from source: ${inspection.removedPipelines.join(", ")}.`,
788
855
  { hint: { description: "Restore the pipeline in source, or run toloka project untrack pipeline <name> to stop managing it." } },
789
856
  );
857
+ const detachedPipelines = inspection.pipelines
858
+ .filter((pipeline) =>
859
+ pipeline.graphChanges.some(
860
+ (change) => change.label === "root dataset binding" && change.action === "removed",
861
+ ),
862
+ )
863
+ .map((pipeline) => pipeline.source.sourceDirectory);
864
+ assertCli(
865
+ detachedPipelines.length === 0,
866
+ `Refusing to unbind the dataset of ${detachedPipelines.join(", ")}: the source declares no dataset, but the pipeline has one on the platform.`,
867
+ { hint: { description: "Declare the dataset in definePipeline; toloka import writes the name of the bound dataset." } },
868
+ );
869
+
790
870
  assertCli(
791
871
  inspection.removedDatasets.length === 0,
792
872
  `Refusing to delete datasets absent from source: ${inspection.removedDatasets.join(", ")}. Dataset items are never deleted.`,
@@ -806,6 +886,19 @@ export async function applyProject(
806
886
  { hint: { description: "Add the changed columns under new field names, or create a new dataset for the new schema." } },
807
887
  );
808
888
 
889
+ const ruleViolations = inspection.pipelines
890
+ .filter((pipeline) => pipeline.pipeline === undefined || pipeline.graphChanges.length > 0)
891
+ .flatMap((pipeline) =>
892
+ platformRuleIssues(pipeline.source.definition.graph).map(
893
+ (issue) => `- ${pipeline.source.sourceDirectory}, node ${issue.node}: ${issue.reason}`,
894
+ ),
895
+ );
896
+ assertCli(
897
+ ruleViolations.length === 0,
898
+ `The platform would reject these pipelines:\n${ruleViolations.join("\n")}`,
899
+ { hint: { description: "Fix the listed nodes in source; toloka check reports the same rules as warnings." } },
900
+ );
901
+
809
902
  let writes = 0;
810
903
  const datasetIds = new Map<string, string>();
811
904
  for (const datasetInspection of inspection.datasets) {
@@ -832,40 +925,9 @@ export async function applyProject(
832
925
 
833
926
  const pipelineIds = new Map<string, string>();
834
927
  for (const pipelineInspection of inspection.pipelines) {
835
- const datasetId = datasetIds.get(pipelineInspection.source.definition.dataset);
836
- assertCli(
837
- datasetId,
838
- `Dataset ${pipelineInspection.source.definition.dataset} has no resolved ID.`,
839
- { hint: REPORT_HINT },
840
- );
841
- const desiredGraph = resolvePipelineDefinition(pipelineInspection.source.definition, {
842
- datasetId,
843
- });
844
- if (pipelineInspection.pipeline === undefined) {
845
- const pipeline = await client.pipelines.import({
846
- projectId: inspection.projectId,
847
- name: pipelineInspection.source.name,
848
- graph: desiredGraph,
849
- });
850
- await client.pipelines.attachDataset({ pipelineId: pipeline.id, datasetId });
851
- pipelineIds.set(pipelineInspection.source.sourceDirectory, pipeline.id);
852
- writes += 2;
853
- continue;
854
- }
855
- pipelineIds.set(pipelineInspection.source.sourceDirectory, pipelineInspection.pipeline.id);
856
- if (pipelineInspection.graphChanges.length === 0) continue;
857
- const snapshot = pipelineInspection.snapshot;
858
- assertCli(
859
- snapshot,
860
- `Pipeline ${pipelineInspection.pipeline.id} has no graph snapshot.`,
861
- { hint: REPORT_HINT },
862
- );
863
- await client.pipelines.replaceGraph({
864
- pipelineId: pipelineInspection.pipeline.id,
865
- graph: graphWithLiveState(desiredGraph, snapshot.graph),
866
- expectedVersion: snapshot.version,
867
- });
868
- writes += 1;
928
+ const applied = await applyPipeline(client, inspection.projectId, pipelineInspection, datasetIds);
929
+ pipelineIds.set(pipelineInspection.source.sourceDirectory, applied.pipelineId);
930
+ writes += applied.writes;
869
931
  }
870
932
 
871
933
  const nextState: TolokaState = {
@@ -910,6 +972,10 @@ const issueLocation = (issue: { nodeId?: string; checkId?: string }) =>
910
972
  [issue.nodeId, issue.checkId].filter((value): value is string => value !== undefined).join("/");
911
973
 
912
974
  const PENDING_CHECK_CODE = "not_ran_node_check";
975
+ const RUNNING_CHECK_CODE = "running_node_check";
976
+
977
+ const hasRunningChecks = (report: PipelineValidationReport) =>
978
+ report.issues.some((issue) => issue.code === RUNNING_CHECK_CODE);
913
979
 
914
980
  const normalizeValidationIssue = (issue: PipelineValidationIssue): PipelineValidationIssue =>
915
981
  issue.code === "empty_node_checks"
@@ -948,72 +1014,156 @@ const normalizeValidationReport = (
948
1014
  const formatValidationIssues = (report: PipelineValidationReport) =>
949
1015
  report.issues.map((issue) => {
950
1016
  const location = issueLocation(issue);
951
- const severity = issue.code === PENDING_CHECK_CODE ? "pending" : issue.severity;
1017
+ const severity =
1018
+ issue.code === PENDING_CHECK_CODE
1019
+ ? "pending"
1020
+ : issue.code === RUNNING_CHECK_CODE
1021
+ ? "running"
1022
+ : issue.severity;
952
1023
  return ` ${severity} ${issue.code}${location.length === 0 ? "" : ` ${location}`}: ${issue.message}`;
953
1024
  });
954
1025
 
1026
+ type NormalizedValidation = ReturnType<typeof normalizeValidationReport>;
1027
+
1028
+ type RunningValidation = {
1029
+ readonly index: number;
1030
+ readonly validate: () => Promise<NormalizedValidation>;
1031
+ readonly failure?: string;
1032
+ };
1033
+
1034
+ const sleep = (ms: number) =>
1035
+ new Promise<void>((resolve) => {
1036
+ setTimeout(resolve, ms);
1037
+ });
1038
+
1039
+ const pollValidationEntry = async (
1040
+ results: PipelineValidationResult[],
1041
+ entry: RunningValidation,
1042
+ ): Promise<RunningValidation | undefined> => {
1043
+ try {
1044
+ const { report, status } = await entry.validate();
1045
+ results[entry.index] = { ...results[entry.index]!, report, status };
1046
+ return hasRunningChecks(report) ? { index: entry.index, validate: entry.validate } : undefined;
1047
+ } catch (error) {
1048
+ return { ...entry, failure: error instanceof Error ? error.message : String(error) };
1049
+ }
1050
+ };
1051
+
1052
+ const waitForRunningChecks = async (
1053
+ results: PipelineValidationResult[],
1054
+ running: readonly RunningValidation[],
1055
+ wait: NonNullable<ValidateProjectOptions["wait"]>,
1056
+ startedAt: number,
1057
+ ) => {
1058
+ const deadline = startedAt + wait.timeoutMs;
1059
+ let pending = running;
1060
+ while (pending.length > 0 && Date.now() < deadline) {
1061
+ await sleep(Math.min(wait.pollIntervalMs, deadline - Date.now()));
1062
+ const next: RunningValidation[] = [];
1063
+ for (const entry of pending) {
1064
+ const retry = await pollValidationEntry(results, entry);
1065
+ if (retry !== undefined) next.push(retry);
1066
+ }
1067
+ pending = next;
1068
+ }
1069
+ const waitedSeconds = Math.round((Date.now() - startedAt) / 1000);
1070
+ for (const entry of pending) {
1071
+ const result = results[entry.index]!;
1072
+ results[entry.index] = {
1073
+ ...result,
1074
+ status: result.status === "invalid" ? "invalid" : "checks_running",
1075
+ note:
1076
+ entry.failure === undefined
1077
+ ? `checks are still running after ${waitedSeconds}s; run toloka validate --wait again`
1078
+ : `validation failed while waiting (${entry.failure}); run toloka validate --wait again`,
1079
+ };
1080
+ }
1081
+ };
1082
+
1083
+ const validatePipelineEntry = async (
1084
+ client: PotatoClient,
1085
+ inspection: ProjectInspection,
1086
+ pipeline: ProjectInspection["pipelines"][number],
1087
+ wait: ValidateProjectOptions["wait"],
1088
+ ): Promise<{
1089
+ readonly result: PipelineValidationResult;
1090
+ readonly poll?: () => Promise<NormalizedValidation>;
1091
+ }> => {
1092
+ const { sourceDirectory } = pipeline.source;
1093
+ const datasetName = pipeline.source.definition.dataset;
1094
+ const dataset =
1095
+ datasetName === undefined
1096
+ ? undefined
1097
+ : inspection.datasets.find((entry) => entry.name === datasetName)?.dataset;
1098
+ if (pipeline.pipeline === undefined)
1099
+ return { result: { sourceDirectory, skipped: "pipeline is not on the platform yet; run toloka apply first" } };
1100
+ if (datasetName !== undefined && dataset === undefined)
1101
+ return {
1102
+ result: {
1103
+ sourceDirectory,
1104
+ pipelineId: pipeline.pipeline.id,
1105
+ skipped: `dataset "${datasetName}" is not on the platform yet; run toloka apply first`,
1106
+ },
1107
+ };
1108
+ const resolved = resolvePipelineDefinition(
1109
+ pipeline.source.definition,
1110
+ dataset === undefined ? {} : { datasetId: dataset.id },
1111
+ );
1112
+ const graph = pipeline.snapshot === undefined ? resolved : graphWithLiveState(resolved, pipeline.snapshot.graph);
1113
+ const pipelineId = pipeline.pipeline.id;
1114
+ const validatePipeline = async () =>
1115
+ normalizeValidationReport(await client.pipelines.validateGraph({ pipelineId, graph }));
1116
+ const { report, status } = await validatePipeline();
1117
+ const waitForChecks = wait !== undefined && hasRunningChecks(report);
1118
+ const unapplied = waitForChecks && pipeline.graphChanges.length > 0;
1119
+ return {
1120
+ result: {
1121
+ sourceDirectory,
1122
+ pipelineId,
1123
+ report,
1124
+ status,
1125
+ ...(unapplied
1126
+ ? { note: "lints run on the applied graph; run toloka apply, then toloka validate --wait" }
1127
+ : {}),
1128
+ },
1129
+ ...(waitForChecks && !unapplied ? { poll: validatePipeline } : {}),
1130
+ };
1131
+ };
1132
+
1133
+ const formatValidationResult = (result: PipelineValidationResult): string[] => {
1134
+ if (result.skipped !== undefined) return [`- ${result.sourceDirectory}: skipped (${result.skipped})`];
1135
+ assertCli(result.report, `Pipeline ${result.sourceDirectory} is missing a validation report`, {
1136
+ hint: REPORT_HINT,
1137
+ });
1138
+ assertCli(result.status, `Pipeline ${result.sourceDirectory} is missing a validation status`, {
1139
+ hint: REPORT_HINT,
1140
+ });
1141
+ return [
1142
+ `- ${result.sourceDirectory}: ${result.status.replace("_", "-")}`,
1143
+ ...formatValidationIssues(result.report),
1144
+ ...(result.note === undefined ? [] : [` note: ${result.note}`]),
1145
+ ];
1146
+ };
1147
+
955
1148
  export async function validateProject(
956
1149
  client: PotatoClient,
957
1150
  options: ValidateProjectOptions,
958
1151
  ): Promise<ValidateProjectResult> {
1152
+ const startedAt = Date.now();
959
1153
  const inspection = await inspectProject(client, options);
1154
+ const { wait } = options;
960
1155
  const results: PipelineValidationResult[] = [];
1156
+ const running: RunningValidation[] = [];
961
1157
  for (const pipeline of inspection.pipelines) {
962
- const dataset = inspection.datasets.find(
963
- (entry) => entry.name === pipeline.source.definition.dataset,
964
- )?.dataset;
965
- if (pipeline.pipeline === undefined) {
966
- results.push({
967
- sourceDirectory: pipeline.source.sourceDirectory,
968
- skipped: "pipeline is not on the platform yet; run toloka apply first",
969
- });
970
- continue;
971
- }
972
- if (dataset === undefined) {
973
- results.push({
974
- sourceDirectory: pipeline.source.sourceDirectory,
975
- pipelineId: pipeline.pipeline.id,
976
- skipped: `dataset "${pipeline.source.definition.dataset}" is not on the platform yet; run toloka apply first`,
977
- });
978
- continue;
979
- }
980
- const graph =
981
- pipeline.snapshot === undefined
982
- ? resolvePipelineDefinition(pipeline.source.definition, { datasetId: dataset.id })
983
- : graphWithLiveState(
984
- resolvePipelineDefinition(pipeline.source.definition, { datasetId: dataset.id }),
985
- pipeline.snapshot.graph,
986
- );
987
- const normalized = normalizeValidationReport(
988
- await client.pipelines.validateGraph({
989
- pipelineId: pipeline.pipeline.id,
990
- graph,
991
- }),
992
- );
993
- results.push({
994
- sourceDirectory: pipeline.source.sourceDirectory,
995
- pipelineId: pipeline.pipeline.id,
996
- report: normalized.report,
997
- status: normalized.status,
998
- });
1158
+ const { result, poll } = await validatePipelineEntry(client, inspection, pipeline, wait);
1159
+ results.push(result);
1160
+ if (poll !== undefined) running.push({ index: results.length - 1, validate: poll });
999
1161
  }
1000
- const valid = results.every((result) => result.status !== "invalid");
1001
- const lines = results.flatMap((result) => {
1002
- if (result.skipped !== undefined)
1003
- return [`- ${result.sourceDirectory}: skipped (${result.skipped})`];
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
- );
1014
- const header = `- ${result.sourceDirectory}: ${result.status.replace("_", "-")}`;
1015
- return [header, ...formatValidationIssues(result.report)];
1016
- });
1162
+ if (wait !== undefined) await waitForRunningChecks(results, running, wait, startedAt);
1163
+ const valid = results.every(
1164
+ (result) => result.status !== "invalid" && result.status !== "checks_running",
1165
+ );
1166
+ const lines = results.flatMap(formatValidationResult);
1017
1167
  return {
1018
1168
  projectId: inspection.projectId,
1019
1169
  results,
@@ -313,6 +313,16 @@ const uploadPromptFile = async (
313
313
  }
314
314
  };
315
315
 
316
+ const assertDatasetIdOption = (
317
+ flag: "--dataset" | "--validation-dataset",
318
+ value: string | undefined,
319
+ ): void =>
320
+ assertCli(
321
+ value === undefined || /^ds\./i.test(value),
322
+ `${flag} takes a Dataset id (ds.…), got ${JSON.stringify(value)}.`,
323
+ { hint: { description: "Run toloka dataset list and pass the Dataset id (ds.…)." } },
324
+ );
325
+
316
326
  const resolveSelections = async ({
317
327
  client,
318
328
  current,
@@ -322,6 +332,8 @@ const resolveSelections = async ({
322
332
  current: Training["configuration"] | undefined;
323
333
  options: TrainingDraftOptions;
324
334
  }): Promise<ResolvedSelections> => {
335
+ assertDatasetIdOption("--dataset", options.datasetId);
336
+ assertDatasetIdOption("--validation-dataset", options.validationDatasetId);
325
337
  const trainingDatasetId = options.datasetId ?? current?.training.datasetId;
326
338
  assertCli(trainingDatasetId, "--dataset is required.", {
327
339
  hint: { description: "Pass --dataset <dataset-id>; toloka dataset list shows the ids." },
package/src/cli/ui.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { TaskUiComponentDescription } from "../testing/types";
2
+ import type { DescribeTaskUiComponents } from "../testing/task-ui-browser";
3
+ import type { CliIo } from "./context";
4
+
5
+ const formatList = (title: string, items: readonly string[]): string[] =>
6
+ items.length === 0 ? [] : [`${title}:`, ...items.map((item) => `- ${item}`)];
7
+
8
+ export const formatComponentIndex = (
9
+ components: Readonly<Record<string, TaskUiComponentDescription>>,
10
+ ): string =>
11
+ [
12
+ ...Object.values(components).map(
13
+ ({ name, description, whenToUse }) => `${name}: ${description} When to use: ${whenToUse}`,
14
+ ),
15
+ "",
16
+ "Run `toloka ui components <name> [name...]` for the props, value shape, and example of a component.",
17
+ ].join("\n");
18
+
19
+ export const formatComponentDetails = (
20
+ components: Readonly<Record<string, TaskUiComponentDescription>>,
21
+ ): string =>
22
+ Object.values(components)
23
+ .map((component) =>
24
+ [
25
+ `## ${component.name}`,
26
+ component.description,
27
+ `When to use: ${component.whenToUse}`,
28
+ ...formatList("Capabilities", component.capabilities),
29
+ ...formatList("Limitations", component.limitations),
30
+ ...formatList("Best practices", component.bestPractices),
31
+ "Props:",
32
+ component.propsType,
33
+ ...(component.valueType === undefined ? [] : ["Value:", component.valueType]),
34
+ ...(component.example === undefined
35
+ ? []
36
+ : ["Example:", component.example.jsxTemplate, "Example output:", component.example.output]),
37
+ ].join("\n"),
38
+ )
39
+ .join("\n\n");
40
+
41
+ export const uiComponentsCommand = async ({
42
+ sourceRoot,
43
+ names,
44
+ json,
45
+ io,
46
+ describe,
47
+ }: {
48
+ sourceRoot: string;
49
+ names: readonly string[];
50
+ json: boolean;
51
+ io: CliIo;
52
+ describe: DescribeTaskUiComponents;
53
+ }): Promise<void> => {
54
+ const components = await describe({
55
+ resolveFrom: sourceRoot,
56
+ ...(names.length === 0 ? {} : { names: [...names] }),
57
+ });
58
+ if (json) {
59
+ io.stdout(JSON.stringify(components, undefined, 2));
60
+ return;
61
+ }
62
+ io.stdout(names.length === 0 ? formatComponentIndex(components) : formatComponentDetails(components));
63
+ };
package/src/client.ts CHANGED
@@ -652,6 +652,10 @@ const createClientImplementation = (config: ClientConfig) => {
652
652
  list: async () => (await wire.listDeliveryOrders(requestOptions)).deliveryOrders,
653
653
  };
654
654
 
655
+ const generationModels = {
656
+ list: () => wire.listGenerationModels(requestOptions),
657
+ };
658
+
655
659
  const audience = {
656
660
  specializations: {
657
661
  listPage: (params: ListAudienceSpecializationsParams = {}) =>
@@ -674,6 +678,7 @@ const createClientImplementation = (config: ClientConfig) => {
674
678
  pipelines,
675
679
  datasets,
676
680
  baseModels,
681
+ generationModels,
677
682
  models,
678
683
  trainings,
679
684
  runs,