@toloka-platform/sdk 0.11.1 → 0.11.2

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.
@@ -10,8 +10,14 @@ import type { CliAnalytics } from "./analytics";
10
10
  import type { BrowserLoginFlow } from "./browser-login";
11
11
  import { createBrowserSessionAuth, findBrowserLogin } from "./browser-session";
12
12
  import type { checkProject } from "./check";
13
- import { PRODUCTION_BASE_URL, resolveApiKey, resolveBaseUrl, resolveTolokaConfig } from "./config";
14
- import type { ResolvedTolokaConfig, TolokaEnvironment } from "./config";
13
+ import type { DescribeTaskUiComponents } from "../testing/task-ui-browser";
14
+ import {
15
+ PRODUCTION_BASE_URL,
16
+ resolveBaseUrl,
17
+ resolveConfiguredCredential,
18
+ resolveTolokaConfig,
19
+ } from "./config";
20
+ import type { CliCredential, ResolvedTolokaConfig, TolokaEnvironment } from "./config";
15
21
  import { findCommandCredentials } from "./credentials";
16
22
  import type { StoredCredentials } from "./credentials";
17
23
  import { resolveProjectContext } from "./project-context";
@@ -36,6 +42,7 @@ export type ProgramContext = {
36
42
  readonly browserLogin?: BrowserLoginFlow;
37
43
  readonly runChecks?: typeof checkProject;
38
44
  readonly analytics?: CliAnalytics;
45
+ readonly taskUiComponents?: DescribeTaskUiComponents;
39
46
  };
40
47
 
41
48
  export type GlobalOptions = {
@@ -54,6 +61,7 @@ export type Resolved = {
54
61
  readonly sourceRoot: string;
55
62
  readonly client: PotatoClient;
56
63
  readonly baseUrl: string;
64
+ readonly credential: CliCredential;
57
65
  readonly state: TolokaState | undefined;
58
66
  readonly stored: StoredCredentials | undefined;
59
67
  readonly projectId?: string;
@@ -61,10 +69,11 @@ export type Resolved = {
61
69
 
62
70
  export const CREDENTIAL_PRECEDENCE = [
63
71
  "Credentials resolve in this order, highest first:",
64
- " 1. --api-key / --base-url",
65
- " 2. TOLOKA_API_KEY / TOLOKA_BASE_URL",
66
- " 3. the API key stored by toloka login --api-key-stdin",
67
- " 4. the browser login stored by toloka login",
72
+ " 1. --api-key",
73
+ " 2. TOLOKA_SCOPED_TOKEN",
74
+ " 3. TOLOKA_API_KEY",
75
+ " 4. the API key stored by toloka login --api-key-stdin",
76
+ " 5. the browser login stored by toloka login",
68
77
  `The platform defaults to ${PRODUCTION_BASE_URL}.`,
69
78
  ].join("\n");
70
79
 
@@ -205,7 +214,9 @@ export const resolveInitRoot = (globals: GlobalOptions): string =>
205
214
  export const readCommandCredentials = async (globals: GlobalOptions, context: ProgramContext) => {
206
215
  const { found, warning } = await findCommandCredentials(
207
216
  context.environment,
208
- globals.apiKey ?? context.environment.TOLOKA_API_KEY,
217
+ globals.apiKey ??
218
+ context.environment.TOLOKA_SCOPED_TOKEN ??
219
+ context.environment.TOLOKA_API_KEY,
209
220
  );
210
221
  if (warning !== undefined) context.io.stderr(warning);
211
222
  return found?.credentials;
@@ -229,7 +240,7 @@ export const resolveClient = async (
229
240
  ): Promise<{ readonly config: ResolvedTolokaConfig; readonly client: PotatoClient }> => {
230
241
  const { environment } = context;
231
242
  const browserLogin =
232
- resolveApiKey(globals, environment, stored) === undefined
243
+ resolveConfiguredCredential(globals, environment, stored) === undefined
233
244
  ? (await findBrowserLogin(resolveBaseUrl(globals, environment, stored), environment))?.provider
234
245
  : undefined;
235
246
  const config = resolveTolokaConfig(globals, state, environment, stored, browserLogin);
@@ -239,10 +250,16 @@ export const resolveClient = async (
239
250
  createClient(
240
251
  credential.type === "apiKey"
241
252
  ? { baseUrl, apiKey: credential.apiKey }
242
- : {
243
- baseUrl,
244
- accessToken: createBrowserSessionAuth({ provider: credential.provider, baseUrl, environment }),
245
- },
253
+ : credential.type === "scopedToken"
254
+ ? { baseUrl, scopedToken: credential.scopedToken }
255
+ : {
256
+ baseUrl,
257
+ accessToken: createBrowserSessionAuth({
258
+ provider: credential.provider,
259
+ baseUrl,
260
+ environment,
261
+ }),
262
+ },
246
263
  );
247
264
  return { config, client };
248
265
  };
@@ -261,6 +278,7 @@ export const resolveContext = async (
261
278
  sourceRoot,
262
279
  client,
263
280
  baseUrl: config.baseUrl,
281
+ credential: config.credential,
264
282
  state,
265
283
  stored,
266
284
  ...(config.projectId === undefined ? {} : { projectId: config.projectId }),
@@ -192,12 +192,12 @@ export async function findStoredCredentials(
192
192
 
193
193
  export async function findCommandCredentials(
194
194
  environment: CliEnvironment,
195
- overrideApiKey: string | undefined,
195
+ overrideCredential: string | undefined,
196
196
  ): Promise<CommandCredentialLookup> {
197
197
  try {
198
198
  return { found: await findStoredCredentials(environment) };
199
199
  } catch (error) {
200
- if (!(error instanceof CredentialsError) || overrideApiKey === undefined) throw error;
200
+ if (!(error instanceof CredentialsError) || overrideCredential === undefined) throw error;
201
201
  return { found: undefined, warning: error.message };
202
202
  }
203
203
  }
@@ -6,7 +6,11 @@ const LEGACY_ENVIRONMENT_NAMES: Readonly<Record<string, string>> = {
6
6
  POTATO_PROJECT_ID: "TOLOKA_PROJECT_ID",
7
7
  };
8
8
 
9
- export const API_KEY_ENVIRONMENT_NAMES = ["TOLOKA_API_KEY", "POTATO_API_KEY"];
9
+ export const CREDENTIAL_ENVIRONMENT_NAMES = [
10
+ "TOLOKA_SCOPED_TOKEN",
11
+ "TOLOKA_API_KEY",
12
+ "POTATO_API_KEY",
13
+ ];
10
14
 
11
15
  export type NormalizedEnvironment = {
12
16
  readonly environment: TolokaEnvironment;
@@ -5,6 +5,7 @@ import type { CliAnalytics, CliAnalyticsCommandOutcome } from "./analytics";
5
5
  import type { AutoUpdateHooks } from "./auto-update";
6
6
  import { maybeAutoUpdate } from "./auto-update";
7
7
  import type { BrowserLoginFlow } from "./browser-login";
8
+ import type { DescribeTaskUiComponents } from "../testing/task-ui-browser";
8
9
  import { CHECK_RESULT_FILE_ENV, type checkProject } from "./check";
9
10
  import { registerAudienceCommands } from "./commands/audience";
10
11
  import { registerAuthCommands } from "./commands/auth";
@@ -20,6 +21,7 @@ import { registerSkillCommands } from "./commands/skill";
20
21
  import { registerSourceCommands } from "./commands/source";
21
22
  import { registerStatusCommand } from "./commands/status";
22
23
  import { registerTrainingCommands } from "./commands/training";
24
+ import { registerUiCommands } from "./commands/ui";
23
25
  import { registerVersionCommand } from "./commands/version";
24
26
  import type { TolokaEnvironment } from "./config";
25
27
  import {
@@ -103,6 +105,7 @@ export function createProgram(context: ProgramContext): CreatedProgram {
103
105
  registerStatusCommand(program, host);
104
106
  registerReportCommand(program, host);
105
107
  registerSkillCommands(program, host);
108
+ registerUiCommands(program, host);
106
109
 
107
110
  return { program, exitCode };
108
111
  }
@@ -198,6 +201,7 @@ export async function runTolokaCli(
198
201
  runChecks?: typeof checkProject,
199
202
  autoUpdate?: AutoUpdateHooks,
200
203
  browserLogin?: BrowserLoginFlow,
204
+ taskUiComponents?: DescribeTaskUiComponents,
201
205
  ): Promise<number> {
202
206
  const { environment, warnings } = normalizeEnvironment(rawEnvironment);
203
207
  for (const warning of warnings) io.stderr(warning);
@@ -220,6 +224,7 @@ export async function runTolokaCli(
220
224
  ...(injectedClient === undefined ? {} : { injectedClient }),
221
225
  ...(browserLogin === undefined ? {} : { browserLogin }),
222
226
  ...(runChecks === undefined ? {} : { runChecks }),
227
+ ...(taskUiComponents === undefined ? {} : { taskUiComponents }),
223
228
  };
224
229
  const { program, exitCode } = createProgram(baseContext);
225
230
  const startedAt = Date.now();
@@ -50,7 +50,7 @@ export const NO_PROJECT_SELECTED_MESSAGE =
50
50
  "No project selected. Run toloka project create for a new project, toloka project add for an existing project, or cd into a checkout.";
51
51
 
52
52
  const NETWORK_MISMATCH_MESSAGE =
53
- "project add writes the machine registry; unset --api-key / --base-url / TOLOKA_API_KEY / TOLOKA_BASE_URL, or run login against that platform first.";
53
+ "project add writes the machine registry; unset --api-key / --base-url / TOLOKA_SCOPED_TOKEN / TOLOKA_API_KEY / TOLOKA_BASE_URL, or run login against that platform first.";
54
54
 
55
55
  const ALIAS_PATTERN = /^[^/\s]+$/u;
56
56
 
@@ -187,7 +187,10 @@ const assertStoredNetworkMatches = (
187
187
  environment: TolokaEnvironment,
188
188
  stored: StoredCredentials,
189
189
  ) => {
190
- const flagOrEnvKey = firstNonEmpty(flags.apiKey) ?? firstNonEmpty(environment.TOLOKA_API_KEY);
190
+ const flagOrEnvKey =
191
+ firstNonEmpty(flags.apiKey) ??
192
+ firstNonEmpty(environment.TOLOKA_SCOPED_TOKEN) ??
193
+ firstNonEmpty(environment.TOLOKA_API_KEY);
191
194
  const flagOrEnvBase =
192
195
  firstNonEmpty(flags.baseUrl) ??
193
196
  firstNonEmpty(environment.TOLOKA_BASE_URL) ??
package/src/cli/ui.ts ADDED
@@ -0,0 +1,61 @@
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(({ name, description }) => `${name}: ${description}`),
13
+ "",
14
+ "Run `toloka ui components <name> [name...]` for the props, value shape, and example of a component.",
15
+ ].join("\n");
16
+
17
+ export const formatComponentDetails = (
18
+ components: Readonly<Record<string, TaskUiComponentDescription>>,
19
+ ): string =>
20
+ Object.values(components)
21
+ .map((component) =>
22
+ [
23
+ `## ${component.name}`,
24
+ component.description,
25
+ `When to use: ${component.whenToUse}`,
26
+ ...formatList("Capabilities", component.capabilities),
27
+ ...formatList("Limitations", component.limitations),
28
+ ...formatList("Best practices", component.bestPractices),
29
+ "Props:",
30
+ component.propsType,
31
+ ...(component.valueType === undefined ? [] : ["Value:", component.valueType]),
32
+ ...(component.example === undefined
33
+ ? []
34
+ : ["Example:", component.example.jsxTemplate, "Example output:", component.example.output]),
35
+ ].join("\n"),
36
+ )
37
+ .join("\n\n");
38
+
39
+ export const uiComponentsCommand = async ({
40
+ sourceRoot,
41
+ names,
42
+ json,
43
+ io,
44
+ describe,
45
+ }: {
46
+ sourceRoot: string;
47
+ names: readonly string[];
48
+ json: boolean;
49
+ io: CliIo;
50
+ describe: DescribeTaskUiComponents;
51
+ }): Promise<void> => {
52
+ const components = await describe({
53
+ resolveFrom: sourceRoot,
54
+ ...(names.length === 0 ? {} : { names: [...names] }),
55
+ });
56
+ if (json) {
57
+ io.stdout(JSON.stringify(components, undefined, 2));
58
+ return;
59
+ }
60
+ io.stdout(names.length === 0 ? formatComponentIndex(components) : formatComponentDetails(components));
61
+ };
@@ -0,0 +1,32 @@
1
+ [
2
+ "JUDGEMENT",
3
+ "EXPLANATION",
4
+ "PROMPT",
5
+ "USER_PROMPT",
6
+ "RESPONSE",
7
+ "RESPONSE_EVALUATION",
8
+ "RUBRIC",
9
+ "RUBRICS_GROUP",
10
+ "DIALOG",
11
+ "DIALOG_STEP",
12
+ "DIALOG_EVALUATION",
13
+ "SHORT_ANSWER",
14
+ "FEEDBACK",
15
+ "CONTEXT",
16
+ "IMAGE",
17
+ "ANNOTATION",
18
+ "SINGLE_LABEL",
19
+ "MULTI_LABEL",
20
+ "VIDEO",
21
+ "DOCUMENT",
22
+ "GOLDEN_SET",
23
+ "LOGS",
24
+ "SOLUTION",
25
+ "CODE",
26
+ "METADATA",
27
+ "AUDIO",
28
+ "AGENT_TRAJECTORY",
29
+ "MCP_SERVER",
30
+ "CONTAINER",
31
+ "CUSTOM"
32
+ ]
@@ -1,5 +1,9 @@
1
1
  import { assert } from "es-toolkit";
2
- import { chromium, type Browser, type Page } from "playwright";
2
+ import { createRequire } from "node:module";
3
+ import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import type * as Playwright from "playwright";
6
+ import type { Browser, Page } from "playwright";
3
7
  import {
4
8
  TaskUiInfrastructureError,
5
9
  type TaskUiComponentDescription,
@@ -95,8 +99,20 @@ export const readDataPath = (state: Record<string, unknown>): string | undefined
95
99
  export const componentIds = (tree: string): string[] =>
96
100
  [...tree.matchAll(/\bid="([^"]+)"/gu)].map((match) => match[1]!);
97
101
 
98
- export const launchDefaultBrowser = async (): Promise<Browser> => {
102
+ const playwrightSpecifier = (resolveFrom: string | undefined): string => {
103
+ if (resolveFrom === undefined) return "playwright";
99
104
  try {
105
+ return pathToFileURL(createRequire(join(resolveFrom, "package.json")).resolve("playwright")).href;
106
+ } catch {
107
+ return "playwright";
108
+ }
109
+ };
110
+
111
+ export const launchDefaultBrowser = async ({
112
+ resolveFrom,
113
+ }: { resolveFrom?: string } = {}): Promise<Browser> => {
114
+ try {
115
+ const { chromium }: typeof Playwright = await import(playwrightSpecifier(resolveFrom));
100
116
  return await chromium.launch({ headless: true });
101
117
  } catch (error) {
102
118
  throw new TaskUiInfrastructureError(
@@ -117,4 +133,31 @@ export const waitForTaskUiApi = async (page: Page): Promise<void> => {
117
133
  );
118
134
  };
119
135
 
136
+ export const readComponentDocumentation = async (
137
+ page: Page,
138
+ launcherUrl: string,
139
+ names?: string[],
140
+ ): Promise<Record<string, TaskUiComponentDescription>> => {
141
+ await page.goto(launcherUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
142
+ await waitForTaskUiApi(page);
143
+ return selectComponentDocumentation(
144
+ await evaluateWithApi(page, { operation: "getComponentsDescriptions" }),
145
+ names,
146
+ );
147
+ };
148
+
149
+ export type DescribeTaskUiComponents = (input: {
150
+ names?: string[];
151
+ resolveFrom?: string;
152
+ }) => Promise<Record<string, TaskUiComponentDescription>>;
153
+
154
+ export const describeTaskUiComponents: DescribeTaskUiComponents = async ({ names, resolveFrom }) => {
155
+ const browser = await launchDefaultBrowser({ resolveFrom });
156
+ try {
157
+ return await readComponentDocumentation(await browser.newPage(), DEFAULT_TBX_LAUNCHER_URL, names);
158
+ } finally {
159
+ await browser.close();
160
+ }
161
+ };
162
+
120
163
  export type { Browser, Page, TbxTemplate };
@@ -9,6 +9,7 @@ import {
9
9
  componentIds,
10
10
  evaluateWithApi,
11
11
  launchDefaultBrowser,
12
+ readComponentDocumentation,
12
13
  readDataPath,
13
14
  selectComponentDocumentation,
14
15
  waitForTaskUiApi,
@@ -287,15 +288,7 @@ const createTaskUiTest = ({
287
288
  const context = await _uiBrowser.newContext();
288
289
  const page = await context.newPage();
289
290
  try {
290
- await page.goto(_uiConfig.launcherUrl, {
291
- waitUntil: "domcontentloaded",
292
- timeout: 30_000,
293
- });
294
- await waitForTaskUiApi(page);
295
- return selectComponentDocumentation(
296
- await evaluateWithApi(page, { operation: "getComponentsDescriptions" }),
297
- names,
298
- );
291
+ return await readComponentDocumentation(page, _uiConfig.launcherUrl, names);
299
292
  } finally {
300
293
  await page.close();
301
294
  await context.close();
package/src/transport.ts CHANGED
@@ -2,12 +2,17 @@ import { assert } from "es-toolkit";
2
2
  import { PotatoApiError } from "./errors";
3
3
 
4
4
  const BASE_URL_HEADER = "x-potato-sdk-base-url";
5
- const SCOPED_TOKEN_PREFIX = "pst_";
5
+ export const SCOPED_TOKEN_PREFIX = "pst_";
6
6
  const SCOPED_TOKEN_HEADER = "X-Scoped-Token";
7
7
 
8
8
  export type ClientCredential =
9
- | { readonly apiKey: string; readonly accessToken?: undefined }
10
- | { readonly accessToken: () => Promise<string>; readonly apiKey?: undefined };
9
+ | { readonly apiKey: string; readonly accessToken?: undefined; readonly scopedToken?: undefined }
10
+ | {
11
+ readonly accessToken: () => Promise<string>;
12
+ readonly apiKey?: undefined;
13
+ readonly scopedToken?: undefined;
14
+ }
15
+ | { readonly scopedToken: string; readonly accessToken?: undefined; readonly apiKey?: undefined };
11
16
 
12
17
  type TransportRequestInit = RequestInit & {
13
18
  readonly fetch?: typeof globalThis.fetch;
@@ -29,6 +34,13 @@ export const createRequestOptions = (
29
34
  const accessToken = credential.accessToken;
30
35
  return { headers, fetch, authorization: async () => `Bearer ${await accessToken()}` };
31
36
  }
37
+ if (credential.scopedToken !== undefined) {
38
+ assert(credential.scopedToken.length > 0, "The SDK scoped token cannot be empty");
39
+ return {
40
+ headers: { ...headers, [SCOPED_TOKEN_HEADER]: credential.scopedToken },
41
+ fetch,
42
+ };
43
+ }
32
44
 
33
45
  const { apiKey } = credential;
34
46
  assert(apiKey.length > 0, "The SDK API key cannot be empty");
@@ -51,14 +63,24 @@ const prepareRequestInit = (requestOptions: RequestInit, headers: Headers): Requ
51
63
  // those against the base's origin, dropping its path. Join them instead, so a base URL served under a
52
64
  // path prefix (a gateway in front of the platform) keeps that prefix.
53
65
  const resolveRequestUrl = (url: string, baseUrl: string): URL => {
54
- if (!url.startsWith("/") || url.startsWith("//")) return new URL(url, baseUrl);
55
66
  const base = new URL(baseUrl);
56
- return new URL(`${base.pathname.replace(/\/+$/, "")}${url}`, base.origin);
67
+ const basePath = base.pathname.replace(/\/+$/, "");
68
+ const suffix = url.startsWith("/") ? url : `/${url}`;
69
+ const resolved = new URL(`${base.origin}${basePath}${suffix}`);
70
+ assert(
71
+ resolved.origin === base.origin,
72
+ "The SDK refused a request whose origin did not match the base URL",
73
+ );
74
+ return resolved;
57
75
  };
58
76
 
59
77
  export function potatoFetch<T>(url: string, options: TransportRequestInit): Promise<T>;
60
78
  export async function potatoFetch(url: string, options: TransportRequestInit): Promise<unknown> {
61
- const { fetch: fetchImplementation = globalThis.fetch, authorization, ...requestOptions } = options;
79
+ const {
80
+ fetch: fetchImplementation = globalThis.fetch,
81
+ authorization,
82
+ ...requestOptions
83
+ } = options;
62
84
  const headers = new Headers(requestOptions.headers);
63
85
  const baseUrl = headers.get(BASE_URL_HEADER);
64
86
  assert(baseUrl, "The SDK base URL is missing");
package/src/validate.ts CHANGED
@@ -2,12 +2,55 @@ import Ajv2020, { type ErrorObject } from 'ajv/dist/2020.js';
2
2
  import addFormats from 'ajv-formats';
3
3
  import { assert } from 'es-toolkit';
4
4
  import type { PipelineGraphV1 } from '../openapi/gen/models';
5
+ import coreEntityTypes from './core-entity-types.json' with { type: 'json' };
5
6
  import { PipelineGraphValidationError, type PipelineGraphValidationIssue } from './errors';
6
7
  import pipelineGraphV1Contract from './pipeline-graph-v1.contract.json' with { type: 'json' };
7
8
 
8
9
  const ajv = new Ajv2020({ strict: false });
9
10
  addFormats(ajv);
10
- const validatePipelineGraph = ajv.compile(pipelineGraphV1Contract);
11
+ const validatePipelineGraph = ajv.compile<PipelineGraphV1>(pipelineGraphV1Contract);
12
+
13
+ const CORE_ENTITY_TYPES = new Set<string>(coreEntityTypes);
14
+ const LABEL_ENTITY_TYPES = new Set(['SINGLE_LABEL', 'MULTI_LABEL']);
15
+ const LABEL_VALUE_PATTERN = /^[a-z0-9]+(_[a-z0-9]+)*$/;
16
+
17
+ type EntityLike = { entityType: string; allowedLabels?: { label: string; value: string }[] };
18
+
19
+ const entityIssues = (
20
+ entities: readonly EntityLike[],
21
+ side: 'input' | 'output',
22
+ prefix: string,
23
+ ): PipelineGraphValidationIssue[] =>
24
+ entities.flatMap((entity, index) => {
25
+ const path = `${prefix}/${side}/${index}`;
26
+ const issues: PipelineGraphValidationIssue[] = [];
27
+ if (!CORE_ENTITY_TYPES.has(entity.entityType)) {
28
+ issues.push({
29
+ path: `${path}/entityType`,
30
+ reason: `must be one of the supported core entity types: ${coreEntityTypes.join(', ')}`,
31
+ });
32
+ }
33
+ if (side === 'output' && LABEL_ENTITY_TYPES.has(entity.entityType)) {
34
+ const labels = entity.allowedLabels ?? [];
35
+ if (labels.length < 2) {
36
+ issues.push({ path: `${path}/allowedLabels`, reason: 'label entities require at least 2 allowedLabels' });
37
+ }
38
+ if (labels.some(({ value }) => !LABEL_VALUE_PATTERN.test(value))) {
39
+ issues.push({ path: `${path}/allowedLabels`, reason: 'label values must be snake_case (e.g. this_is_value)' });
40
+ }
41
+ }
42
+ return issues;
43
+ });
44
+
45
+ const graphEntityIssues = (graph: PipelineGraphV1): PipelineGraphValidationIssue[] =>
46
+ graph.nodes.flatMap((node, index) => {
47
+ if (node.type !== 'generation' && node.type !== 'quorum') return [];
48
+ const prefix = `/nodes/${index}/settings/entities`;
49
+ return [
50
+ ...entityIssues(node.settings.entities.input, 'input', prefix),
51
+ ...entityIssues(node.settings.entities.output, 'output', prefix),
52
+ ];
53
+ });
11
54
 
12
55
  const toValidationIssue = (error: ErrorObject): PipelineGraphValidationIssue => {
13
56
  const property =
@@ -25,7 +68,11 @@ const toValidationIssue = (error: ErrorObject): PipelineGraphValidationIssue =>
25
68
  };
26
69
 
27
70
  export function validate(graph: unknown): asserts graph is PipelineGraphV1 {
28
- if (validatePipelineGraph(graph)) return;
71
+ if (validatePipelineGraph(graph)) {
72
+ const issues = graphEntityIssues(graph);
73
+ if (issues.length > 0) throw PipelineGraphValidationError.fromIssues(issues);
74
+ return;
75
+ }
29
76
 
30
77
  const errors = validatePipelineGraph.errors;
31
78
  assert(errors, 'Pipeline graph validation failed without errors');