@toloka-platform/sdk 0.2.0-preview.3 → 0.2.0-preview.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/toloka.js +136 -136
- package/dist/cli/toloka.js.jsc +0 -0
- package/dist/cli/toloka.js.jsc.sha256 +1 -1
- package/dist/cli/toloka.js.sha256 +1 -1
- package/docs/cli-v2.md +3 -2
- package/docs/cli.md +32 -4
- package/openapi/gen/models/baseModel.ts +1 -0
- package/openapi/gen/models/currentUser.ts +2 -0
- package/openapi/gen/models/currentUserTeamsItem.ts +6 -0
- package/openapi/gen/models/gistingTrainingConfiguration.ts +6 -2
- package/openapi/gen/models/gistingTrainingConfigurationBatchSize.ts +2 -0
- package/openapi/gen/models/gistingTrainingConfigurationGistTokens.ts +2 -0
- package/openapi/gen/models/gistingTrainingConfigurationInput.ts +6 -2
- package/openapi/gen/models/gistingTrainingConfigurationInputBatchSize.ts +2 -0
- package/openapi/gen/models/gistingTrainingConfigurationInputGistTokens.ts +2 -0
- package/openapi/gen/models/index.ts +9 -4
- package/openapi/gen/models/sftTrainingConfiguration.ts +6 -3
- package/openapi/gen/models/sftTrainingConfigurationBatchSize.ts +2 -0
- package/openapi/gen/models/sftTrainingConfigurationInput.ts +6 -3
- package/openapi/gen/models/sftTrainingConfigurationInputBatchSize.ts +2 -0
- package/openapi/gen/models/sftTrainingConfigurationInputLoraRank.ts +2 -0
- package/openapi/gen/models/sftTrainingConfigurationLoraRank.ts +2 -0
- package/package.json +1 -1
- package/skills/catalog.json +10 -10
- package/skills/toloka-build/SKILL.md +28 -17
- package/skills/toloka-build/references/node-semantics.md +7 -7
- package/skills/toloka-build/references/workflow-and-cli.md +13 -10
- package/skills/toloka-fine-tuning/SKILL.md +145 -62
- package/skills/toloka-get-started/SKILL.md +35 -20
- package/skills/toloka-gisting/SKILL.md +156 -66
- package/src/cli/analytics.ts +50 -23
- package/src/cli/auth.ts +31 -12
- package/src/cli/auto-update.ts +12 -6
- package/src/cli/check.ts +76 -29
- package/src/cli/cli-entry.ts +3 -2
- package/src/cli/cli-error.ts +43 -0
- package/src/cli/commands/dataset.ts +13 -3
- package/src/cli/commands/models.ts +6 -7
- package/src/cli/commands/pipeline.ts +6 -7
- package/src/cli/commands/project.ts +9 -16
- package/src/cli/commands/report.ts +6 -2
- package/src/cli/commands/secret.ts +5 -4
- package/src/cli/commands/source.ts +4 -4
- package/src/cli/commands/status.ts +2 -3
- package/src/cli/commands/training.ts +50 -17
- package/src/cli/config.ts +2 -2
- package/src/cli/context.ts +55 -17
- package/src/cli/credentials.ts +12 -4
- package/src/cli/data.ts +96 -34
- package/src/cli/delivery-orders.ts +18 -1
- package/src/cli/file-system.ts +6 -2
- package/src/cli/format-error.ts +45 -2
- package/src/cli/init.ts +13 -7
- package/src/cli/models.ts +43 -12
- package/src/cli/program.ts +4 -55
- package/src/cli/project-context.ts +117 -32
- package/src/cli/project.ts +80 -28
- package/src/cli/report.ts +24 -9
- package/src/cli/runs.ts +33 -8
- package/src/cli/settings.ts +13 -5
- package/src/cli/skill.ts +61 -22
- package/src/cli/source.ts +3 -2
- package/src/cli/terminal-io.ts +13 -6
- package/src/cli/trainings.ts +129 -35
- package/src/cli/version.ts +2 -2
- package/src/client.ts +3 -9
- package/src/transport.ts +13 -1
- package/openapi/gen/models/gistingTrainingConfigurationInputRegime.ts +0 -2
- package/openapi/gen/models/gistingTrainingConfigurationRegime.ts +0 -2
- package/openapi/gen/models/sftTrainingConfigurationInputRegime.ts +0 -2
- package/openapi/gen/models/sftTrainingConfigurationRegime.ts +0 -2
package/src/cli/project.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
961
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:");
|
package/src/cli/runs.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import select from "@inquirer/select";
|
|
2
|
-
import { assert } from "es-toolkit";
|
|
3
2
|
import type { Pipeline, PotatoClient } from "../client";
|
|
4
3
|
import { PotatoApiError } from "../errors";
|
|
5
4
|
import type { TolokaState } from "../state";
|
|
6
5
|
import type { RunItem, RunJob } from "../index";
|
|
6
|
+
import { assertCli } from "./cli-error";
|
|
7
7
|
|
|
8
8
|
export type PipelineRef = {
|
|
9
9
|
readonly pipelineId: string;
|
|
@@ -131,10 +131,18 @@ const uniqueMatch = (
|
|
|
131
131
|
ambiguous: string,
|
|
132
132
|
unknown: string,
|
|
133
133
|
): PipelineRef | undefined => {
|
|
134
|
-
|
|
134
|
+
assertCli(
|
|
135
|
+
matches.length <= 1,
|
|
136
|
+
ambiguous,
|
|
137
|
+
{ hint: { description: "Pass the pipeline id shown by toloka pipeline list instead of the name." } },
|
|
138
|
+
);
|
|
135
139
|
if (matches.length !== 1) return undefined;
|
|
136
140
|
const [ref] = matches;
|
|
137
|
-
|
|
141
|
+
assertCli(
|
|
142
|
+
ref,
|
|
143
|
+
unknown,
|
|
144
|
+
{ hint: { description: "Run toloka pipeline list to see the pipelines of this project." } },
|
|
145
|
+
);
|
|
138
146
|
return ref;
|
|
139
147
|
};
|
|
140
148
|
|
|
@@ -174,7 +182,11 @@ const resolveToken = (token: string, live: readonly PipelineRef[]): PipelineRef
|
|
|
174
182
|
);
|
|
175
183
|
if (basename !== undefined) return basename;
|
|
176
184
|
|
|
177
|
-
|
|
185
|
+
assertCli(
|
|
186
|
+
false,
|
|
187
|
+
unknown,
|
|
188
|
+
{ hint: { description: "Run toloka pipeline list to see the pipelines of this project." } },
|
|
189
|
+
);
|
|
178
190
|
};
|
|
179
191
|
|
|
180
192
|
export async function resolvePipelineRef(options: {
|
|
@@ -188,7 +200,11 @@ export async function resolvePipelineRef(options: {
|
|
|
188
200
|
|
|
189
201
|
if (options.pipeline !== undefined) return resolveToken(options.pipeline, live);
|
|
190
202
|
|
|
191
|
-
|
|
203
|
+
assertCli(
|
|
204
|
+
live.length > 0,
|
|
205
|
+
"This project has no pipelines.",
|
|
206
|
+
{ hint: { description: "Run toloka apply to create the pipelines declared in source." } },
|
|
207
|
+
);
|
|
192
208
|
|
|
193
209
|
if (live.length > 1 && options.select !== undefined) {
|
|
194
210
|
const chosen = await options.select(
|
|
@@ -199,13 +215,22 @@ export async function resolvePipelineRef(options: {
|
|
|
199
215
|
|
|
200
216
|
if (live.length === 1) {
|
|
201
217
|
const [only] = live;
|
|
202
|
-
|
|
203
|
-
|
|
218
|
+
assertCli(
|
|
219
|
+
only,
|
|
220
|
+
"This project has no pipelines.",
|
|
221
|
+
{ hint: { description: "Run toloka apply to create the pipelines declared in source." } },
|
|
222
|
+
);
|
|
223
|
+
assertCli(
|
|
224
|
+
false,
|
|
225
|
+
`Pass a pipeline name. This project has: ${formatPipelineChoice(only)}.`,
|
|
226
|
+
{ hint: { disabled: true } },
|
|
227
|
+
);
|
|
204
228
|
}
|
|
205
229
|
|
|
206
|
-
|
|
230
|
+
assertCli(
|
|
207
231
|
false,
|
|
208
232
|
`This project tracks several pipelines, so name one: ${live.map(formatPipelineChoice).join(", ")}.`,
|
|
233
|
+
{ hint: { disabled: true } },
|
|
209
234
|
);
|
|
210
235
|
}
|
|
211
236
|
|
package/src/cli/settings.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { join } from "node:path";
|
|
|
3
3
|
import { isFalseyFlag, isTruthyFlag } from "./boolean-flag";
|
|
4
4
|
import type { CliEnvironment } from "./credentials";
|
|
5
5
|
import { configDirectory, preparePrivateFilePath } from "./credentials";
|
|
6
|
+
import { TolokaCliError } from "./cli-error";
|
|
7
|
+
import type { TolokaCliErrorOptions } from "./cli-error";
|
|
6
8
|
|
|
7
9
|
export const AUTO_UPDATE_SETTING = "auto-update";
|
|
8
10
|
|
|
@@ -14,8 +16,8 @@ export const DEFAULT_CLI_SETTINGS: CliSettings = {
|
|
|
14
16
|
autoUpdate: true,
|
|
15
17
|
};
|
|
16
18
|
|
|
17
|
-
export class SettingsError extends
|
|
18
|
-
constructor(message: string, options
|
|
19
|
+
export class SettingsError extends TolokaCliError {
|
|
20
|
+
constructor(message: string, options: TolokaCliErrorOptions) {
|
|
19
21
|
super(message, options);
|
|
20
22
|
this.name = "SettingsError";
|
|
21
23
|
}
|
|
@@ -59,7 +61,9 @@ export async function writeCliSettings(
|
|
|
59
61
|
export const parseBooleanSetting = (value: string): boolean => {
|
|
60
62
|
if (isTruthyFlag(value)) return true;
|
|
61
63
|
if (isFalseyFlag(value)) return false;
|
|
62
|
-
throw new SettingsError(`Expected a boolean (true or false), got ${JSON.stringify(value)}
|
|
64
|
+
throw new SettingsError(`Expected a boolean (true or false), got ${JSON.stringify(value)}.`, {
|
|
65
|
+
hint: { description: "Run toloka config set auto-update true or false." },
|
|
66
|
+
});
|
|
63
67
|
};
|
|
64
68
|
|
|
65
69
|
export const formatCliSettings = (settings: CliSettings): string =>
|
|
@@ -71,7 +75,9 @@ export const getCliSetting = (
|
|
|
71
75
|
): { readonly output: string } => {
|
|
72
76
|
if (name === undefined || name.length === 0) return { output: formatCliSettings(settings) };
|
|
73
77
|
if (name !== AUTO_UPDATE_SETTING) {
|
|
74
|
-
throw new SettingsError(`Unknown setting "${name}". Known settings: ${AUTO_UPDATE_SETTING}
|
|
78
|
+
throw new SettingsError(`Unknown setting "${name}". Known settings: ${AUTO_UPDATE_SETTING}.`, {
|
|
79
|
+
hint: { disabled: true },
|
|
80
|
+
});
|
|
75
81
|
}
|
|
76
82
|
return { output: String(settings.autoUpdate) };
|
|
77
83
|
};
|
|
@@ -82,7 +88,9 @@ export async function setCliSetting(
|
|
|
82
88
|
value: string,
|
|
83
89
|
): Promise<{ readonly output: string; readonly settings: CliSettings }> {
|
|
84
90
|
if (name !== AUTO_UPDATE_SETTING) {
|
|
85
|
-
throw new SettingsError(`Unknown setting "${name}". Known settings: ${AUTO_UPDATE_SETTING}
|
|
91
|
+
throw new SettingsError(`Unknown setting "${name}". Known settings: ${AUTO_UPDATE_SETTING}.`, {
|
|
92
|
+
hint: { disabled: true },
|
|
93
|
+
});
|
|
86
94
|
}
|
|
87
95
|
const settings = { autoUpdate: parseBooleanSetting(value) };
|
|
88
96
|
await writeCliSettings(settings, environment);
|
package/src/cli/skill.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { assert } from "es-toolkit";
|
|
2
1
|
import { createHash } from "node:crypto";
|
|
3
2
|
import { copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat } from "node:fs/promises";
|
|
4
3
|
import { dirname, join } from "node:path";
|
|
5
4
|
import { assertSafeDestination, entryStats, hasErrorCode } from "./file-system";
|
|
6
5
|
import { formatCliError } from "./format-error";
|
|
7
6
|
import { releaseChannelFor, SDK_PACKAGE_NAME, SDK_VERSION } from "./sdk-version";
|
|
7
|
+
import { assertCli, REINSTALL_HINT, TolokaCliError } from "./cli-error";
|
|
8
8
|
|
|
9
9
|
export const SKILL_CATALOG_MANIFEST = "catalog.json";
|
|
10
10
|
export const SKILL_INSTALL_ROOT = ".agents/skills";
|
|
@@ -76,31 +76,51 @@ const isSafeRelativePath = (path: string) =>
|
|
|
76
76
|
path.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
|
|
77
77
|
|
|
78
78
|
const parseDescriptor = (value: unknown, invalid: (detail: string) => string): SkillDescriptor => {
|
|
79
|
-
|
|
79
|
+
assertCli(isRecord(value), invalid("every skill must be an object."), { hint: REINSTALL_HINT });
|
|
80
80
|
const { id, name, description, tags, version, digest, files } = value;
|
|
81
|
-
|
|
81
|
+
assertCli(
|
|
82
82
|
isSafeSkillId(id),
|
|
83
83
|
invalid(
|
|
84
84
|
`skill id ${JSON.stringify(id)} must be at most ${MAX_SKILL_ID_LENGTH} lowercase letters, digits, and single hyphens.`,
|
|
85
85
|
),
|
|
86
|
+
{ hint: REINSTALL_HINT },
|
|
86
87
|
);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
assertCli(isNonEmptyString(name), invalid(`skill ${id} needs a name.`), { hint: REINSTALL_HINT });
|
|
89
|
+
assertCli(
|
|
90
|
+
isNonEmptyString(description),
|
|
91
|
+
invalid(`skill ${id} needs a description.`),
|
|
92
|
+
{ hint: REINSTALL_HINT },
|
|
93
|
+
);
|
|
94
|
+
assertCli(
|
|
95
|
+
isStringArray(tags),
|
|
96
|
+
invalid(`skill ${id} tags must be strings.`),
|
|
97
|
+
{ hint: REINSTALL_HINT },
|
|
98
|
+
);
|
|
99
|
+
assertCli(
|
|
100
|
+
isNonEmptyString(version),
|
|
101
|
+
invalid(`skill ${id} needs a version.`),
|
|
102
|
+
{ hint: REINSTALL_HINT },
|
|
103
|
+
);
|
|
104
|
+
assertCli(
|
|
92
105
|
typeof digest === "string" && DIGEST_PATTERN.test(digest),
|
|
93
106
|
invalid(`skill ${id} digest must be the sha256 hex digest of its files.`),
|
|
107
|
+
{ hint: REINSTALL_HINT },
|
|
94
108
|
);
|
|
95
|
-
|
|
109
|
+
assertCli(
|
|
96
110
|
isStringArray(files) && files.includes(SKILL_ENTRY_POINT),
|
|
97
111
|
invalid(`skill ${id} files must include ${SKILL_ENTRY_POINT}.`),
|
|
112
|
+
{ hint: REINSTALL_HINT },
|
|
98
113
|
);
|
|
99
|
-
|
|
114
|
+
assertCli(
|
|
100
115
|
files.every(isSafeRelativePath),
|
|
101
116
|
invalid(`skill ${id} files must be relative paths inside its directory.`),
|
|
117
|
+
{ hint: REINSTALL_HINT },
|
|
118
|
+
);
|
|
119
|
+
assertCli(
|
|
120
|
+
new Set(files).size === files.length,
|
|
121
|
+
invalid(`skill ${id} lists a file more than once.`),
|
|
122
|
+
{ hint: REINSTALL_HINT },
|
|
102
123
|
);
|
|
103
|
-
assert(new Set(files).size === files.length, invalid(`skill ${id} lists a file more than once.`));
|
|
104
124
|
return { id, name, description, tags, version, digest, files };
|
|
105
125
|
};
|
|
106
126
|
|
|
@@ -108,7 +128,10 @@ const parseManifest = (source: string, manifestPath: string): unknown => {
|
|
|
108
128
|
try {
|
|
109
129
|
return JSON.parse(source);
|
|
110
130
|
} catch (error) {
|
|
111
|
-
throw new
|
|
131
|
+
throw new TolokaCliError(`Invalid skill catalog ${manifestPath}: ${formatCliError(error)}`, {
|
|
132
|
+
cause: error,
|
|
133
|
+
hint: REINSTALL_HINT,
|
|
134
|
+
});
|
|
112
135
|
}
|
|
113
136
|
};
|
|
114
137
|
|
|
@@ -140,9 +163,10 @@ export async function resolveBundledSkillCatalogRoot(
|
|
|
140
163
|
entry === undefined
|
|
141
164
|
? "process.argv[1] names no runtime entry"
|
|
142
165
|
: `none exists between ${entry} and the filesystem root`;
|
|
143
|
-
|
|
166
|
+
assertCli(
|
|
144
167
|
workingDirectoryRoot !== undefined,
|
|
145
168
|
`Cannot locate the bundled skill catalog. An installed @toloka-platform/sdk must ship ${SKILL_CATALOG_DIRECTORY}/${SKILL_CATALOG_MANIFEST} in a directory above its runtime entry, but ${searched}, and no ${SKILL_CATALOG_DIRECTORY}/${SKILL_CATALOG_MANIFEST} exists above ${process.cwd()} either. Reinstall @toloka-platform/sdk.`,
|
|
169
|
+
{ hint: { disabled: true } },
|
|
146
170
|
);
|
|
147
171
|
return workingDirectoryRoot;
|
|
148
172
|
}
|
|
@@ -154,23 +178,30 @@ export async function loadSkillCatalog(catalogRoot: string): Promise<SkillCatalo
|
|
|
154
178
|
const manifestPath = join(catalogRoot, SKILL_CATALOG_MANIFEST);
|
|
155
179
|
const invalid = (detail: string) => `Invalid skill catalog ${manifestPath}: ${detail}`;
|
|
156
180
|
const { size } = await stat(manifestPath);
|
|
157
|
-
|
|
181
|
+
assertCli(
|
|
158
182
|
size <= MAX_MANIFEST_BYTES,
|
|
159
183
|
invalid(`the manifest is ${size} bytes; the limit is ${MAX_MANIFEST_BYTES} bytes.`),
|
|
184
|
+
{ hint: REINSTALL_HINT },
|
|
160
185
|
);
|
|
161
186
|
const manifest = parseManifest(await readFile(manifestPath, "utf8"), manifestPath);
|
|
162
|
-
|
|
187
|
+
assertCli(
|
|
163
188
|
isRecord(manifest) && Array.isArray(manifest.skills),
|
|
164
189
|
invalid("expected an object with a skills array."),
|
|
190
|
+
{ hint: REINSTALL_HINT },
|
|
165
191
|
);
|
|
166
|
-
|
|
192
|
+
assertCli(
|
|
167
193
|
manifest.skills.length <= MAX_SKILL_COUNT,
|
|
168
194
|
invalid(`${manifest.skills.length} skills exceed the limit of ${MAX_SKILL_COUNT}.`),
|
|
195
|
+
{ hint: REINSTALL_HINT },
|
|
169
196
|
);
|
|
170
197
|
const skills = manifest.skills.map((skill) => parseDescriptor(skill, invalid));
|
|
171
198
|
const ids = new Set<string>();
|
|
172
199
|
for (const skill of skills) {
|
|
173
|
-
|
|
200
|
+
assertCli(
|
|
201
|
+
!ids.has(skill.id),
|
|
202
|
+
invalid(`skill id ${skill.id} appears more than once.`),
|
|
203
|
+
{ hint: REINSTALL_HINT },
|
|
204
|
+
);
|
|
174
205
|
ids.add(skill.id);
|
|
175
206
|
}
|
|
176
207
|
return { root: catalogRoot, manifestPath, skills };
|
|
@@ -253,9 +284,14 @@ const swapDirectories = async (
|
|
|
253
284
|
try {
|
|
254
285
|
await renameEntry(backup, destination);
|
|
255
286
|
} catch (restoreError) {
|
|
256
|
-
throw new
|
|
287
|
+
throw new TolokaCliError(
|
|
257
288
|
`Failed to replace ${destination}; the previous version is preserved at ${backup}. ${formatCliError(restoreError)}`,
|
|
258
|
-
{
|
|
289
|
+
{
|
|
290
|
+
cause: error,
|
|
291
|
+
hint: {
|
|
292
|
+
description: `Move ${backup} back to ${destination} by hand, then re-run toloka skill install <skill-id> --force.`,
|
|
293
|
+
},
|
|
294
|
+
},
|
|
259
295
|
);
|
|
260
296
|
}
|
|
261
297
|
throw error;
|
|
@@ -271,17 +307,19 @@ export async function installSkill({
|
|
|
271
307
|
rename: renameEntry = rename,
|
|
272
308
|
}: InstallSkillOptions): Promise<InstalledSkill> {
|
|
273
309
|
const skill = catalog.skills.find((candidate) => candidate.id === skillId);
|
|
274
|
-
|
|
310
|
+
assertCli(
|
|
275
311
|
skill,
|
|
276
312
|
`Skill ${JSON.stringify(skillId)} is not in the catalog bundled with ${SDK_PACKAGE_NAME} ${SDK_VERSION}. Run \`toloka skill list\` to see the available skills; a newer release may ship it (${UPGRADE_HINT}).`,
|
|
313
|
+
{ hint: { disabled: true } },
|
|
277
314
|
);
|
|
278
315
|
const relativeDestination = `${SKILL_INSTALL_ROOT}/${skill.id}`;
|
|
279
316
|
await assertSafeDestination(sourceRoot, relativeDestination);
|
|
280
317
|
const destination = join(sourceRoot, relativeDestination);
|
|
281
318
|
const existing = await entryStats(destination);
|
|
282
|
-
|
|
319
|
+
assertCli(
|
|
283
320
|
existing === undefined || force,
|
|
284
321
|
`${relativeDestination} already exists. Re-run with --force to replace it with the catalog version of ${skill.id}.`,
|
|
322
|
+
{ hint: { disabled: true } },
|
|
285
323
|
);
|
|
286
324
|
|
|
287
325
|
const skillsRoot = dirname(destination);
|
|
@@ -293,9 +331,10 @@ export async function installSkill({
|
|
|
293
331
|
await mkdir(dirname(target), { recursive: true });
|
|
294
332
|
await copyFile(join(catalog.root, skill.id, path), target);
|
|
295
333
|
}
|
|
296
|
-
|
|
334
|
+
assertCli(
|
|
297
335
|
(await computeSkillDigest(temporary, skill.files)) === skill.digest,
|
|
298
336
|
`Skill ${skill.id} failed its digest check: the packaged files do not match the catalog digest ${skill.digest}. Nothing was installed.`,
|
|
337
|
+
{ hint: REINSTALL_HINT },
|
|
299
338
|
);
|
|
300
339
|
if (existing === undefined) {
|
|
301
340
|
await renameEntry(temporary, destination);
|