dataiku-sdk 3.9.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/build-metadata.json +1 -1
- package/dist/packages/types/src/index.d.ts +6 -0
- package/dist/packages/types/src/index.js +6 -0
- package/dist/src/cli/coerce.d.ts +6 -0
- package/dist/src/cli/coerce.js +9 -0
- package/dist/src/cli/command-syntax.json +216 -0
- package/dist/src/cli/commands/app.js +2 -2
- package/dist/src/cli/commands/code-env.js +10 -9
- package/dist/src/cli/commands/code.js +16 -2
- package/dist/src/cli/commands/dashboard.js +1 -2
- package/dist/src/cli/commands/dataset.js +6 -4
- package/dist/src/cli/commands/flow-zone.js +1 -1
- package/dist/src/cli/commands/job.js +70 -16
- package/dist/src/cli/commands/plugin.js +7 -7
- package/dist/src/cli/commands/project-library.js +2 -2
- package/dist/src/cli/commands/project.js +21 -4
- package/dist/src/cli/commands/recipe.js +34 -21
- package/dist/src/cli/commands/scenario.js +78 -12
- package/dist/src/cli/contract.js +30 -7
- package/dist/src/cli/env.d.ts +6 -0
- package/dist/src/cli/env.js +10 -3
- package/dist/src/cli/helpers/app-permissions.d.ts +1 -1
- package/dist/src/cli/helpers/app-permissions.js +2 -2
- package/dist/src/cli/helpers/cleanup.d.ts +2 -0
- package/dist/src/cli/helpers/cleanup.js +122 -8
- package/dist/src/cli/helpers/flow-zone.d.ts +1 -1
- package/dist/src/cli/helpers/flow-zone.js +3 -4
- package/dist/src/cli/list-output.d.ts +5 -0
- package/dist/src/cli/list-output.js +11 -2
- package/dist/src/cli/list-shapes.d.ts +6 -0
- package/dist/src/cli/list-shapes.js +7 -1
- package/dist/src/cli/output-schemas.d.ts +1 -1
- package/dist/src/cli/output-schemas.js +1 -1
- package/dist/src/cli/output.d.ts +2 -0
- package/dist/src/cli/output.js +31 -4
- package/dist/src/cli/read-shapes.js +90 -0
- package/dist/src/cli/result-shapes.d.ts +19 -1
- package/dist/src/cli/result-shapes.js +227 -4
- package/dist/src/cli/selector-suggestions.d.ts +14 -0
- package/dist/src/cli/selector-suggestions.js +56 -0
- package/dist/src/cli/usage.d.ts +1 -1
- package/dist/src/cli/usage.js +29 -4
- package/dist/src/cli/version.d.ts +2 -2
- package/dist/src/cli/version.js +4 -5
- package/dist/src/cli.js +128 -30
- package/dist/src/client.d.ts +15 -2
- package/dist/src/client.js +36 -11
- package/dist/src/errors.d.ts +2 -0
- package/dist/src/errors.js +12 -2
- package/dist/src/generated/action-output-schemas.json +232 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/resources/datasets.d.ts +11 -1
- package/dist/src/resources/datasets.js +91 -30
- package/dist/src/resources/jobs.d.ts +35 -5
- package/dist/src/resources/jobs.js +68 -41
- package/dist/src/resources/projects.js +2 -2
- package/dist/src/resources/recipe-create.d.ts +4 -1
- package/dist/src/resources/recipe-create.js +11 -2
- package/dist/src/resources/recipes.d.ts +12 -1
- package/dist/src/resources/recipes.js +39 -12
- package/dist/src/resources/scenarios.d.ts +8 -1
- package/dist/src/resources/scenarios.js +51 -12
- package/dist/src/resources/sql.js +3 -3
- package/dist/src/resources/wiki.js +3 -7
- package/dist/src/skill.js +1 -2
- package/dist/src/utils/project-archive.d.ts +25 -0
- package/dist/src/utils/project-archive.js +216 -5
- package/dist/src/utils/response-file.js +15 -8
- package/package.json +12 -12
- package/plugin.json +1 -1
- package/skills/dataiku-dss/SKILL.md +15 -17
- package/skills/dataiku-dss/references/discovery.md +2 -2
- package/skills/dataiku-dss/references/troubleshooting.md +1 -0
|
@@ -66,6 +66,12 @@ function jobBuildDefinition(targets, defaultProjectKey, opts) {
|
|
|
66
66
|
function jobLogLines(log) {
|
|
67
67
|
return log.split(/\r?\n/).map((line) => line.trimEnd());
|
|
68
68
|
}
|
|
69
|
+
function countLogLines(log) {
|
|
70
|
+
if (!log)
|
|
71
|
+
return 0;
|
|
72
|
+
const lines = jobLogLines(log);
|
|
73
|
+
return lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
|
|
74
|
+
}
|
|
69
75
|
/**
|
|
70
76
|
* DSS tags recipe subprocess output by reader thread: `[null-out-N]` carries
|
|
71
77
|
* the child's stdout and `[null-err-N]` its stderr. The legacy fallback is an
|
|
@@ -81,6 +87,13 @@ const LEGACY_STDERR_RECORD = /^\s*stderr:/i;
|
|
|
81
87
|
* continuation of that record (exception chain, JVM frames, ...).
|
|
82
88
|
*/
|
|
83
89
|
const DSS_RECORD_HEADER = /^\[\d{4}\/\d{2}\/\d{2}-\d{2}:\d{2}:\d{2}\.\d{3}\]/;
|
|
90
|
+
/**
|
|
91
|
+
* Job-level outcome line the job engine writes outside any record, e.g.
|
|
92
|
+
* `2026-10-12T09:13:22.189: Job is incompatible with build state: [...]` when
|
|
93
|
+
* another running job already builds the target. It carries no level word but
|
|
94
|
+
* is the only reason DSS gives for such a failure.
|
|
95
|
+
*/
|
|
96
|
+
const JOB_OUTCOME_LINE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}: \S/;
|
|
84
97
|
const ERROR_KEYWORDS = /\b(error|failed|failure|exception|traceback)\b/i;
|
|
85
98
|
/**
|
|
86
99
|
* Exception class names ending in the `Exception`/`Error` suffix (e.g.
|
|
@@ -149,7 +162,7 @@ function lineMatchesLogFilter(line, filter) {
|
|
|
149
162
|
case "stderr":
|
|
150
163
|
return normalized.includes("[null-err-") || LEGACY_STDERR_RECORD.test(line);
|
|
151
164
|
case "errors":
|
|
152
|
-
return isErrorContent(line);
|
|
165
|
+
return isErrorContent(line) || JOB_OUTCOME_LINE.test(line);
|
|
153
166
|
case "user":
|
|
154
167
|
return !/^\d{4}[-/]\d{2}[-/]\d{2}/.test(line)
|
|
155
168
|
&& !normalized.includes("backend-log")
|
|
@@ -195,6 +208,11 @@ function filterJobLogToErrors(lines) {
|
|
|
195
208
|
}
|
|
196
209
|
pyTraceback = false;
|
|
197
210
|
}
|
|
211
|
+
if (JOB_OUTCOME_LINE.test(line)) {
|
|
212
|
+
kept.push(line);
|
|
213
|
+
recordMatches = false;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
198
216
|
if (isHeader) {
|
|
199
217
|
recordMatches = isErrorContent(stripJsonPayloads(line)) || PY_FILE_FRAME.test(message);
|
|
200
218
|
if (recordMatches && (PY_TRACEBACK_HEAD.test(message) || PY_FILE_FRAME.test(message))) {
|
|
@@ -322,22 +340,39 @@ export class JobsResource extends BaseResource {
|
|
|
322
340
|
* Use `0` or `-1` to return the log without line truncation. Credential-like
|
|
323
341
|
* JSON pairs (`"jobTicketSecret":"..."`) are redacted.
|
|
324
342
|
*
|
|
325
|
-
* The download is byte-bounded
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
343
|
+
* The download is byte-bounded: at most `maxLogBytes` (default 10 MiB) of
|
|
344
|
+
* the *most recent* log output is retained, so a collaborator-influenced
|
|
345
|
+
* oversized log cannot exhaust client memory while the line limit
|
|
346
|
+
* previously only applied after a full unbounded download. The request
|
|
347
|
+
* timeout is a stall deadline (per received chunk), so a large log that
|
|
348
|
+
* keeps arriving is not cut off by a total-transfer budget. Logs up to the
|
|
349
|
+
* cap are returned in full. Use {@link logDetails} to learn whether bytes
|
|
350
|
+
* were dropped.
|
|
330
351
|
*/
|
|
331
352
|
async log(jobId, opts) {
|
|
353
|
+
return (await this.logDetails(jobId, opts)).text;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Same retrieval as {@link log}, additionally reporting whether the
|
|
357
|
+
* byte-bounded download dropped early log bytes (`truncated`) and how many
|
|
358
|
+
* lines were scanned before filtering (`scannedLines`).
|
|
359
|
+
*/
|
|
360
|
+
async logDetails(jobId, opts) {
|
|
332
361
|
const jobEnc = encodeURIComponent(jobId);
|
|
333
362
|
const query = opts?.activity ? `?activity=${encodeURIComponent(opts.activity)}` : "";
|
|
334
363
|
// DSS cat-activity-log URLs require a browser session; API-key callers must use the public log endpoint.
|
|
335
364
|
const path = `/public/api/projects/${this.enc(opts?.projectKey)}/jobs/${jobEnc}/log/${query}`;
|
|
336
365
|
const maxLogBytes = Math.max(1, opts?.maxLogBytes ?? DEFAULT_MAX_LOG_BYTES);
|
|
337
|
-
const { text, } = await this.client.getTextTailLimited(path, maxLogBytes);
|
|
366
|
+
const { text, truncated, } = await this.client.getTextTailLimited(path, maxLogBytes);
|
|
338
367
|
// Single download choke point (logFromUrl and wait/summary go through here):
|
|
339
368
|
// DSS logs embed the job ticket secret in start_session JSON.
|
|
340
|
-
|
|
369
|
+
const redacted = redactCredentialPairs(text);
|
|
370
|
+
return {
|
|
371
|
+
text: limitJobLog(filterJobLog(redacted, opts?.logFilter), opts?.maxLogLines),
|
|
372
|
+
truncated,
|
|
373
|
+
scannedLines: countLogLines(redacted),
|
|
374
|
+
maxLogBytes,
|
|
375
|
+
};
|
|
341
376
|
}
|
|
342
377
|
async logFromUrl(logUrl, opts) {
|
|
343
378
|
const parsed = new URL(logUrl, "http://dss.local");
|
|
@@ -438,6 +473,28 @@ export class JobsResource extends BaseResource {
|
|
|
438
473
|
const startedAt = Date.now();
|
|
439
474
|
let pollCount = 0;
|
|
440
475
|
let lastJ;
|
|
476
|
+
// The wait only stops polling: the job keeps running in DSS and is never
|
|
477
|
+
// cancelled, so the result says so explicitly.
|
|
478
|
+
const timedOutResult = (last, elapsedMs) => {
|
|
479
|
+
const bs = last?.baseStatus ?? {};
|
|
480
|
+
const gs = last?.globalState ?? {};
|
|
481
|
+
return {
|
|
482
|
+
success: false,
|
|
483
|
+
jobId,
|
|
484
|
+
state: bs.state ?? "unknown",
|
|
485
|
+
type: bs.def?.type ?? "unknown",
|
|
486
|
+
elapsedMs,
|
|
487
|
+
pollCount,
|
|
488
|
+
timedOut: true,
|
|
489
|
+
cancelled: false,
|
|
490
|
+
progress: {
|
|
491
|
+
done: gs.done ?? 0,
|
|
492
|
+
failed: gs.failed ?? 0,
|
|
493
|
+
running: gs.running ?? 0,
|
|
494
|
+
total: gs.total ?? null,
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
};
|
|
441
498
|
while (true) {
|
|
442
499
|
const elapsedBeforeMs = Date.now() - startedAt;
|
|
443
500
|
// The first observation always happens, even when the budget is
|
|
@@ -445,24 +502,7 @@ export class JobsResource extends BaseResource {
|
|
|
445
502
|
// the loop reports the structured timeout from the last observed
|
|
446
503
|
// state instead of issuing a request the budget cannot cover.
|
|
447
504
|
if (lastJ !== undefined && elapsedBeforeMs >= timeout) {
|
|
448
|
-
|
|
449
|
-
const def = bs.def ?? {};
|
|
450
|
-
const gs = lastJ.globalState ?? {};
|
|
451
|
-
return {
|
|
452
|
-
success: false,
|
|
453
|
-
jobId,
|
|
454
|
-
state: bs.state ?? "unknown",
|
|
455
|
-
type: def.type ?? "unknown",
|
|
456
|
-
elapsedMs: elapsedBeforeMs,
|
|
457
|
-
pollCount,
|
|
458
|
-
timedOut: true,
|
|
459
|
-
progress: {
|
|
460
|
-
done: gs.done ?? 0,
|
|
461
|
-
failed: gs.failed ?? 0,
|
|
462
|
-
running: gs.running ?? 0,
|
|
463
|
-
total: gs.total ?? null,
|
|
464
|
-
},
|
|
465
|
-
};
|
|
505
|
+
return timedOutResult(lastJ, elapsedBeforeMs);
|
|
466
506
|
}
|
|
467
507
|
pollCount += 1;
|
|
468
508
|
// Requests issued while budget remains are bounded by the remaining
|
|
@@ -482,21 +522,8 @@ export class JobsResource extends BaseResource {
|
|
|
482
522
|
// error escape the loop.
|
|
483
523
|
if (!isRequestDeadlineError(error, startedAt + timeout))
|
|
484
524
|
throw error;
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
jobId,
|
|
488
|
-
state: "unknown",
|
|
489
|
-
type: "unknown",
|
|
490
|
-
elapsedMs: Date.now() - startedAt,
|
|
491
|
-
pollCount,
|
|
492
|
-
timedOut: true,
|
|
493
|
-
progress: {
|
|
494
|
-
done: 0,
|
|
495
|
-
failed: 0,
|
|
496
|
-
running: 0,
|
|
497
|
-
total: null,
|
|
498
|
-
},
|
|
499
|
-
};
|
|
525
|
+
// A deadline mid-poll keeps the last state a previous poll observed.
|
|
526
|
+
return timedOutResult(lastJ, Date.now() - startedAt);
|
|
500
527
|
}
|
|
501
528
|
lastJ = j;
|
|
502
529
|
const bs = j.baseStatus ?? {};
|
|
@@ -188,7 +188,7 @@ export class ProjectsResource extends BaseResource {
|
|
|
188
188
|
&& settings.targetProjectKey.trim() !== ""
|
|
189
189
|
? { targetProjectKey: settings.targetProjectKey, }
|
|
190
190
|
: {}),
|
|
191
|
-
});
|
|
191
|
+
}, { cause: error, });
|
|
192
192
|
}
|
|
193
193
|
throw error;
|
|
194
194
|
}
|
|
@@ -246,7 +246,7 @@ export class ProjectsResource extends BaseResource {
|
|
|
246
246
|
catch (error) {
|
|
247
247
|
if (error instanceof DataikuError
|
|
248
248
|
&& (error.status === 404 || error.status === 0 || error.status >= 500)) {
|
|
249
|
-
throw new ClientValidationError(`DSS reported import success for project ${usedProjectKey}, but the project could not be re-read for verification.`, "ambiguous_outcome", PROJECT_IMPORT_AMBIGUOUS_REMEDIATION, { importId, usedProjectKey, });
|
|
249
|
+
throw new ClientValidationError(`DSS reported import success for project ${usedProjectKey}, but the project could not be re-read for verification.`, "ambiguous_outcome", PROJECT_IMPORT_AMBIGUOUS_REMEDIATION, { importId, usedProjectKey, }, { cause: error, });
|
|
250
250
|
}
|
|
251
251
|
throw error;
|
|
252
252
|
}
|
|
@@ -30,7 +30,10 @@ export declare function groupingPayloadConfig(opts: RecipeCreateOptions): Groupi
|
|
|
30
30
|
/**
|
|
31
31
|
* Apply grouping keys and aggregates to the payload DSS created: `keys` lists
|
|
32
32
|
* the group columns; each aggregated column's `values[]` entry gets its
|
|
33
|
-
* aggregate flags (and `orderColumn` for first/last).
|
|
33
|
+
* aggregate flags (and `orderColumn` for first/last). With aggregates given,
|
|
34
|
+
* DSS's per-group row count (`globalCount`, a `count` column) is turned off so
|
|
35
|
+
* the output holds exactly the keys and the requested aggregates; `COL:count`
|
|
36
|
+
* asks for a count. Keys alone keep the count as the only aggregate.
|
|
34
37
|
*/
|
|
35
38
|
export declare function applyGroupingPayload(payload: Record<string, unknown>, config: GroupingPayloadConfig): Record<string, unknown>;
|
|
36
39
|
/** Prepare option groups, in the default step order. */
|
|
@@ -164,7 +164,10 @@ export function groupingPayloadConfig(opts) {
|
|
|
164
164
|
/**
|
|
165
165
|
* Apply grouping keys and aggregates to the payload DSS created: `keys` lists
|
|
166
166
|
* the group columns; each aggregated column's `values[]` entry gets its
|
|
167
|
-
* aggregate flags (and `orderColumn` for first/last).
|
|
167
|
+
* aggregate flags (and `orderColumn` for first/last). With aggregates given,
|
|
168
|
+
* DSS's per-group row count (`globalCount`, a `count` column) is turned off so
|
|
169
|
+
* the output holds exactly the keys and the requested aggregates; `COL:count`
|
|
170
|
+
* asks for a count. Keys alone keep the count as the only aggregate.
|
|
168
171
|
*/
|
|
169
172
|
export function applyGroupingPayload(payload, config) {
|
|
170
173
|
const values = Array.isArray(payload.values)
|
|
@@ -182,7 +185,13 @@ export function applyGroupingPayload(payload, config) {
|
|
|
182
185
|
entry.orderColumn = config.orderBy;
|
|
183
186
|
}
|
|
184
187
|
}
|
|
185
|
-
|
|
188
|
+
const hasAggregates = Object.keys(config.aggregates).length > 0;
|
|
189
|
+
return {
|
|
190
|
+
...payload,
|
|
191
|
+
keys: config.keys.map((column) => ({ column, })),
|
|
192
|
+
values,
|
|
193
|
+
...(hasAggregates ? { globalCount: false, } : {}),
|
|
194
|
+
};
|
|
186
195
|
}
|
|
187
196
|
function processorStep(type, params) {
|
|
188
197
|
return {
|
|
@@ -65,6 +65,14 @@ export interface RecipeCloneResult {
|
|
|
65
65
|
payloadTextRewrites: Record<string, string>;
|
|
66
66
|
copiedOutputDatasets: string[];
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Validate an update patch against the current recipe document and return the
|
|
70
|
+
* document to PUT. `recipe` is deep-merged, except `recipe.params.envSelection`,
|
|
71
|
+
* which the patch replaces wholesale so no stale `envName` survives an
|
|
72
|
+
* EXPLICIT_ENV to INHERIT switch. `envSelection` outside `recipe.params` is
|
|
73
|
+
* rejected: neither DSS nor the CLI reads it there.
|
|
74
|
+
*/
|
|
75
|
+
export declare function buildRecipeUpdate(current: Record<string, unknown>, data: Record<string, unknown>): Record<string, unknown>;
|
|
68
76
|
export declare class RecipesResource extends BaseResource {
|
|
69
77
|
/** List all recipes in a project. */
|
|
70
78
|
list(projectKey?: string): Promise<RecipeSummary[]>;
|
|
@@ -136,7 +144,10 @@ export declare class RecipesResource extends BaseResource {
|
|
|
136
144
|
}): Promise<RecipeUpdateSchemaResult>;
|
|
137
145
|
/**
|
|
138
146
|
* Update a recipe by merging the patch into the current definition.
|
|
139
|
-
* The `recipe` sub-object is deep-merged to preserve nested fields
|
|
147
|
+
* The `recipe` sub-object is deep-merged to preserve nested fields, except
|
|
148
|
+
* `recipe.params.envSelection`: when the patch provides it, the whole object
|
|
149
|
+
* is replaced (not merged), so switching EXPLICIT_ENV to INHERIT cannot leave
|
|
150
|
+
* a stale `envName` behind. `envSelection` outside `recipe.params` is rejected.
|
|
140
151
|
*/
|
|
141
152
|
update(recipeName: string, data: Record<string, unknown>, projectKey?: string): Promise<void>;
|
|
142
153
|
/** Replace a full recipe API document. */
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { writeFile, } from "node:fs/promises";
|
|
2
1
|
import { resolve, } from "node:path";
|
|
3
2
|
import { ClientValidationError, DataikuError, nonJsonResponseBody, unexpectedResponseError, } from "../errors.js";
|
|
4
3
|
import { ProjectMetadataSchema, RecipeSchemaUpdatesSchema, RecipeSummaryArraySchema, } from "../schemas.js";
|
|
@@ -40,6 +39,37 @@ const RECIPE_DEFINITION_FIELDS = new Set(["params", "inputs", "outputs", "script
|
|
|
40
39
|
function rootRecipeDefinitionFields(data) {
|
|
41
40
|
return Object.keys(data).filter((key) => RECIPE_DEFINITION_FIELDS.has(key));
|
|
42
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Validate an update patch against the current recipe document and return the
|
|
44
|
+
* document to PUT. `recipe` is deep-merged, except `recipe.params.envSelection`,
|
|
45
|
+
* which the patch replaces wholesale so no stale `envName` survives an
|
|
46
|
+
* EXPLICIT_ENV to INHERIT switch. `envSelection` outside `recipe.params` is
|
|
47
|
+
* rejected: neither DSS nor the CLI reads it there.
|
|
48
|
+
*/
|
|
49
|
+
export function buildRecipeUpdate(current, data) {
|
|
50
|
+
const currentRecipe = asRecord(current.recipe) ?? {};
|
|
51
|
+
const recipePatch = asRecord(data.recipe) ?? {};
|
|
52
|
+
const misplacedEnvSelection = [
|
|
53
|
+
...("envSelection" in data ? ["envSelection",] : []),
|
|
54
|
+
...("envSelection" in recipePatch ? ["recipe.envSelection",] : []),
|
|
55
|
+
];
|
|
56
|
+
if (misplacedEnvSelection.length > 0) {
|
|
57
|
+
throw new ClientValidationError(`envSelection at ${misplacedEnvSelection.join(" and ")} is ignored; it only applies under recipe.params. Example: {"recipe":{"params":{"envSelection":{"envMode":"EXPLICIT_ENV","envName":"py311_env"}}}}`, "validation_failed");
|
|
58
|
+
}
|
|
59
|
+
const misplacedRecipeFields = rootRecipeDefinitionFields(data);
|
|
60
|
+
if (misplacedRecipeFields.length > 0) {
|
|
61
|
+
throw new ClientValidationError(`Recipe fields ${misplacedRecipeFields.join(", ")} must be nested under "recipe". Example: {"recipe":{"outputs":{...},"params":{...}}}`, "validation_failed");
|
|
62
|
+
}
|
|
63
|
+
const mergedRecipe = deepMerge(currentRecipe, recipePatch);
|
|
64
|
+
const paramsPatch = asRecord(recipePatch.params);
|
|
65
|
+
if (paramsPatch && "envSelection" in paramsPatch) {
|
|
66
|
+
mergedRecipe.params = {
|
|
67
|
+
...asRecord(mergedRecipe.params),
|
|
68
|
+
envSelection: paramsPatch.envSelection,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { ...current, ...data, recipe: mergedRecipe, };
|
|
72
|
+
}
|
|
43
73
|
function normalizeRecipeOutputType(value) {
|
|
44
74
|
if (typeof value !== "string")
|
|
45
75
|
return undefined;
|
|
@@ -823,22 +853,19 @@ export class RecipesResource extends BaseResource {
|
|
|
823
853
|
}
|
|
824
854
|
/**
|
|
825
855
|
* Update a recipe by merging the patch into the current definition.
|
|
826
|
-
* The `recipe` sub-object is deep-merged to preserve nested fields
|
|
856
|
+
* The `recipe` sub-object is deep-merged to preserve nested fields, except
|
|
857
|
+
* `recipe.params.envSelection`: when the patch provides it, the whole object
|
|
858
|
+
* is replaced (not merged), so switching EXPLICIT_ENV to INHERIT cannot leave
|
|
859
|
+
* a stale `envName` behind. `envSelection` outside `recipe.params` is rejected.
|
|
827
860
|
*/
|
|
828
861
|
async update(recipeName, data, projectKey) {
|
|
829
862
|
const enc = this.enc(projectKey);
|
|
830
863
|
const rnEnc = encodeURIComponent(recipeName);
|
|
831
864
|
const current = await this.client.get(`/public/api/projects/${enc}/recipes/${rnEnc}`);
|
|
832
|
-
|
|
833
|
-
if (!currentRecipe) {
|
|
865
|
+
if (!asRecord(current.recipe)) {
|
|
834
866
|
throw new ClientValidationError(`Recipe "${recipeName}" was not found or returned an empty definition.`, "validation_failed");
|
|
835
867
|
}
|
|
836
|
-
const
|
|
837
|
-
if (misplacedRecipeFields.length > 0) {
|
|
838
|
-
throw new ClientValidationError(`Recipe fields ${misplacedRecipeFields.join(", ")} must be nested under "recipe". Example: {"recipe":{"outputs":{...},"params":{...}}}`, "validation_failed");
|
|
839
|
-
}
|
|
840
|
-
const mergedRecipe = deepMerge(currentRecipe, asRecord(data.recipe) ?? {});
|
|
841
|
-
const merged = { ...current, ...data, recipe: mergedRecipe, };
|
|
868
|
+
const merged = buildRecipeUpdate(current, data);
|
|
842
869
|
await this.client.put(`/public/api/projects/${enc}/recipes/${rnEnc}`, merged);
|
|
843
870
|
}
|
|
844
871
|
/** Replace a full recipe API document. */
|
|
@@ -922,7 +949,7 @@ export class RecipesResource extends BaseResource {
|
|
|
922
949
|
}
|
|
923
950
|
const safeRecipeName = sanitizeFileName(recipeName, "recipe");
|
|
924
951
|
const filePath = opts?.outputPath ?? resolve(process.cwd(), `${safeRecipeName}${inferRecipeCodeExtension(result.recipe.type)}`);
|
|
925
|
-
await
|
|
952
|
+
await Bun.write(filePath, result.payload);
|
|
926
953
|
return filePath;
|
|
927
954
|
}
|
|
928
955
|
/** Get only the code payload of a recipe as a raw string. */
|
|
@@ -965,7 +992,7 @@ export class RecipesResource extends BaseResource {
|
|
|
965
992
|
const recipe = await this.client.get(`/public/api/projects/${enc}/recipes/${rnEnc}`);
|
|
966
993
|
const safeRecipeName = sanitizeFileName(recipeName, "recipe");
|
|
967
994
|
const filePath = opts?.outputPath ?? resolve(process.cwd(), `${safeRecipeName}.json`);
|
|
968
|
-
await
|
|
995
|
+
await Bun.write(filePath, JSON.stringify(recipe, null, 2));
|
|
969
996
|
return filePath;
|
|
970
997
|
}
|
|
971
998
|
}
|
|
@@ -107,6 +107,10 @@ export interface ScenarioScriptRunResult {
|
|
|
107
107
|
elapsedMs: number;
|
|
108
108
|
pollCount: number;
|
|
109
109
|
output?: string;
|
|
110
|
+
/** True when the captured script output was cut (wrapper byte budget or log read cap); `output` then holds the last part only. */
|
|
111
|
+
outputTruncated?: boolean;
|
|
112
|
+
/** Bytes of script output the wrapper dropped from the front; absent when the cut happened in the log read instead. */
|
|
113
|
+
outputOmittedBytes?: number;
|
|
110
114
|
log: string;
|
|
111
115
|
logTruncated: boolean;
|
|
112
116
|
maxLogBytes: number;
|
|
@@ -186,12 +190,15 @@ export declare class ScenariosResource extends BaseResource {
|
|
|
186
190
|
* Get the log of a scenario run (GET /scenarios/{id}/{runId}/log), optionally
|
|
187
191
|
* scoped to a single step via `stepId`. The text is byte-bounded: at most
|
|
188
192
|
* `maxLogBytes` (default 1 MiB) of the beginning of the log is retained and
|
|
189
|
-
* `truncated` reports whether the cap cut the body.
|
|
193
|
+
* `truncated` reports whether the cap cut the body. With `tail: true` the
|
|
194
|
+
* last `maxLogBytes` are retained instead (used by code runs, whose output
|
|
195
|
+
* markers sit at the end of the log).
|
|
190
196
|
*/
|
|
191
197
|
getRunLog(scenarioId: string, runId: string, opts?: {
|
|
192
198
|
stepId?: string;
|
|
193
199
|
maxLogBytes?: number;
|
|
194
200
|
projectKey?: string;
|
|
201
|
+
tail?: boolean;
|
|
195
202
|
}): Promise<ScenarioRunLog>;
|
|
196
203
|
/** Get the payload of a (custom) scenario (GET /scenarios/{id}/payload). */
|
|
197
204
|
getPayload(scenarioId: string, opts?: {
|
|
@@ -32,6 +32,14 @@ export class ScenarioScriptRunWithCleanupFailureError extends Error {
|
|
|
32
32
|
const DEFAULT_CODE_RUN_MAX_LOG_BYTES = 1_048_576;
|
|
33
33
|
const CODE_RUN_OUTPUT_START = "<<<DSS_CODE_RUN_OUTPUT_b7e3a1>>>";
|
|
34
34
|
const CODE_RUN_OUTPUT_END = "<<<DSS_CODE_RUN_OUTPUT_END_b7e3a1>>>";
|
|
35
|
+
/** Written right after the END marker when the wrapper cut the captured output; carries the omitted byte count. */
|
|
36
|
+
const CODE_RUN_OUTPUT_TRUNCATED_RE = /^<<<DSS_CODE_RUN_OUTPUT_TRUNCATED_b7e3a1:(\d+)>>>$/;
|
|
37
|
+
/**
|
|
38
|
+
* The wrapper keeps at most `maxLogBytes / CODE_RUN_OUTPUT_BUDGET_DIVISOR` bytes
|
|
39
|
+
* of script output. The remainder of the read cap is headroom for the per-line
|
|
40
|
+
* DSS log prefix, so the markers still fit in what `runScript` reads back.
|
|
41
|
+
*/
|
|
42
|
+
const CODE_RUN_OUTPUT_BUDGET_DIVISOR = 4;
|
|
35
43
|
/** Prefer the current runId field, with the legacy id field as fallback. */
|
|
36
44
|
function triggerRunIdOf(trigger, scenarioId) {
|
|
37
45
|
const runId = trigger?.runId ?? trigger?.id;
|
|
@@ -80,8 +88,10 @@ function scenarioLightStatusFromRaw(raw) {
|
|
|
80
88
|
* wrapper noise. The script is base64-encoded to avoid quoting/escaping issues and
|
|
81
89
|
* exec'd as `__main__`. A failing script re-raises SystemExit(1) so the scenario
|
|
82
90
|
* outcome is FAILED while the captured traceback still lands between the markers.
|
|
91
|
+
* Only the last `maxOutputBytes` UTF-8 bytes of output are re-emitted; when output
|
|
92
|
+
* was cut, a truncation marker with the omitted byte count follows the END marker.
|
|
83
93
|
*/
|
|
84
|
-
function buildCodeRunScript(script) {
|
|
94
|
+
function buildCodeRunScript(script, maxOutputBytes) {
|
|
85
95
|
const encoded = Buffer.from(script, "utf-8").toString("base64");
|
|
86
96
|
return [
|
|
87
97
|
"import base64 as _dku_b64, sys as _dku_sys, io as _dku_io, traceback as _dku_tb",
|
|
@@ -99,9 +109,14 @@ function buildCodeRunScript(script) {
|
|
|
99
109
|
"\t_dku_tb.print_exc()",
|
|
100
110
|
"finally:",
|
|
101
111
|
"\t_dku_sys.stdout, _dku_sys.stderr = _dku_out, _dku_err",
|
|
112
|
+
'\t_dku_bytes = _dku_buf.getvalue().encode("utf-8", "replace")',
|
|
113
|
+
`\t_dku_omitted = max(0, len(_dku_bytes) - ${maxOutputBytes})`,
|
|
114
|
+
'\t_dku_text = _dku_bytes[_dku_omitted:].decode("utf-8", "ignore")',
|
|
102
115
|
`\t_dku_out.write("${CODE_RUN_OUTPUT_START}\\n")`,
|
|
103
|
-
"\t_dku_out.write(
|
|
116
|
+
"\t_dku_out.write(_dku_text)",
|
|
104
117
|
`\t_dku_out.write("\\n${CODE_RUN_OUTPUT_END}\\n")`,
|
|
118
|
+
"\tif _dku_omitted:",
|
|
119
|
+
'\t\t_dku_out.write("<<<DSS_CODE_RUN_OUTPUT_TRUNCATED_b7e3a1:%d>>>\\n" % _dku_omitted)',
|
|
105
120
|
"\t_dku_out.flush()",
|
|
106
121
|
"if _dku_code:",
|
|
107
122
|
"\traise SystemExit(_dku_code)",
|
|
@@ -112,10 +127,13 @@ function buildCodeRunScript(script) {
|
|
|
112
127
|
* Pull the script's own stdout/stderr back out of the full DSS run log by slicing
|
|
113
128
|
* the `[process]` lines between the markers emitted by {@link buildCodeRunScript}.
|
|
114
129
|
* Returns undefined if the markers are absent (e.g. the harness never ran), in which
|
|
115
|
-
* case callers should fall back to the full log.
|
|
130
|
+
* case callers should fall back to the full log. When the log read itself was cut
|
|
131
|
+
* (`logTruncated`) the START marker may be gone while END survives; the output is
|
|
132
|
+
* then whatever precedes END, reported as truncated.
|
|
116
133
|
*/
|
|
117
|
-
function extractCodeRunOutput(log) {
|
|
118
|
-
|
|
134
|
+
function extractCodeRunOutput(log, logTruncated) {
|
|
135
|
+
// `[\s\S]` (not `.`): CR, U+2028 and U+2029 inside a printed line must not drop it.
|
|
136
|
+
const messageRe = /^\[[^\]]*\] \[[^\]]*\] \[[^\]]*\] \[process\] - ([\s\S]*)$/;
|
|
119
137
|
const contents = [];
|
|
120
138
|
for (const rawLine of log.split("\n")) {
|
|
121
139
|
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
@@ -125,7 +143,7 @@ function extractCodeRunOutput(log) {
|
|
|
125
143
|
}
|
|
126
144
|
// First start + last end, so a script that prints a marker string stays body content.
|
|
127
145
|
const start = contents.indexOf(CODE_RUN_OUTPUT_START);
|
|
128
|
-
if (start < 0)
|
|
146
|
+
if (start < 0 && !logTruncated)
|
|
129
147
|
return undefined;
|
|
130
148
|
let end = -1;
|
|
131
149
|
for (let i = contents.length - 1; i > start; i--) {
|
|
@@ -140,7 +158,13 @@ function extractCodeRunOutput(log) {
|
|
|
140
158
|
// Drop only the single trailing separator the harness writes before the end marker.
|
|
141
159
|
if (body.length > 0 && body[body.length - 1] === "")
|
|
142
160
|
body.pop();
|
|
143
|
-
|
|
161
|
+
const omitted = CODE_RUN_OUTPUT_TRUNCATED_RE.exec(contents[end + 1] ?? "");
|
|
162
|
+
const omittedBytes = omitted ? Number(omitted[1]) : undefined;
|
|
163
|
+
return {
|
|
164
|
+
output: body.join("\n"),
|
|
165
|
+
truncated: omitted !== null || start < 0,
|
|
166
|
+
...(omittedBytes !== undefined ? { omittedBytes, } : {}),
|
|
167
|
+
};
|
|
144
168
|
}
|
|
145
169
|
function scenarioStepWarningSummary(stepResult) {
|
|
146
170
|
const container = stepResult?.warnings;
|
|
@@ -441,7 +465,10 @@ export class ScenariosResource extends BaseResource {
|
|
|
441
465
|
type: "custom_python",
|
|
442
466
|
params: { envSelection, },
|
|
443
467
|
});
|
|
444
|
-
await this.client.putVoid(`${base}/payload`, {
|
|
468
|
+
await this.client.putVoid(`${base}/payload`, {
|
|
469
|
+
script: buildCodeRunScript(script, Math.floor(maxLogBytes / CODE_RUN_OUTPUT_BUDGET_DIVISOR)),
|
|
470
|
+
extension: "py",
|
|
471
|
+
});
|
|
445
472
|
const trigger = await this.client.post(`${base}/run/`, {});
|
|
446
473
|
const tracked = await this.trackTriggerRun(scenarioId, base, trigger, {
|
|
447
474
|
startedAt,
|
|
@@ -454,9 +481,12 @@ export class ScenariosResource extends BaseResource {
|
|
|
454
481
|
pollCount = tracked.pollCount;
|
|
455
482
|
timedOut = tracked.timedOut;
|
|
456
483
|
if (runId && outcome !== "TIMEOUT") {
|
|
484
|
+
// Tail read: the markers are printed last, so a log over the cap loses
|
|
485
|
+
// DSS start-up noise instead of the END marker.
|
|
457
486
|
const limitedLog = await this.getRunLog(scenarioId, runId, {
|
|
458
487
|
projectKey: opts?.projectKey,
|
|
459
488
|
maxLogBytes,
|
|
489
|
+
tail: true,
|
|
460
490
|
});
|
|
461
491
|
log = limitedLog.text;
|
|
462
492
|
logTruncated = limitedLog.truncated;
|
|
@@ -475,7 +505,7 @@ export class ScenariosResource extends BaseResource {
|
|
|
475
505
|
}
|
|
476
506
|
throw primaryError;
|
|
477
507
|
}
|
|
478
|
-
const
|
|
508
|
+
const extracted = extractCodeRunOutput(log, logTruncated);
|
|
479
509
|
return {
|
|
480
510
|
scenarioId,
|
|
481
511
|
runId,
|
|
@@ -483,7 +513,11 @@ export class ScenariosResource extends BaseResource {
|
|
|
483
513
|
success: outcome === "SUCCESS",
|
|
484
514
|
elapsedMs: Date.now() - startedAt,
|
|
485
515
|
pollCount,
|
|
486
|
-
output,
|
|
516
|
+
output: extracted?.output,
|
|
517
|
+
...(extracted?.truncated ? { outputTruncated: true, } : {}),
|
|
518
|
+
...(extracted?.omittedBytes !== undefined
|
|
519
|
+
? { outputOmittedBytes: extracted.omittedBytes, }
|
|
520
|
+
: {}),
|
|
487
521
|
log,
|
|
488
522
|
logTruncated,
|
|
489
523
|
maxLogBytes,
|
|
@@ -519,7 +553,9 @@ export class ScenariosResource extends BaseResource {
|
|
|
519
553
|
* Get the log of a scenario run (GET /scenarios/{id}/{runId}/log), optionally
|
|
520
554
|
* scoped to a single step via `stepId`. The text is byte-bounded: at most
|
|
521
555
|
* `maxLogBytes` (default 1 MiB) of the beginning of the log is retained and
|
|
522
|
-
* `truncated` reports whether the cap cut the body.
|
|
556
|
+
* `truncated` reports whether the cap cut the body. With `tail: true` the
|
|
557
|
+
* last `maxLogBytes` are retained instead (used by code runs, whose output
|
|
558
|
+
* markers sit at the end of the log).
|
|
523
559
|
*/
|
|
524
560
|
async getRunLog(scenarioId, runId, opts) {
|
|
525
561
|
if (opts?.stepId !== undefined)
|
|
@@ -529,7 +565,10 @@ export class ScenariosResource extends BaseResource {
|
|
|
529
565
|
const query = opts?.stepId !== undefined
|
|
530
566
|
? `?stepId=${encodeURIComponent(opts.stepId)}`
|
|
531
567
|
: "";
|
|
532
|
-
const
|
|
568
|
+
const readText = opts?.tail === true
|
|
569
|
+
? this.client.getTextTailLimited.bind(this.client)
|
|
570
|
+
: this.client.getTextLimited.bind(this.client);
|
|
571
|
+
const { text, truncated, } = await readText(`/public/api/projects/${this.enc(opts?.projectKey)}/scenarios/${scEnc}/${encodeURIComponent(runId)}/log${query}`, maxLogBytes);
|
|
533
572
|
return { text, truncated, maxLogBytes, };
|
|
534
573
|
}
|
|
535
574
|
/** Get the payload of a (custom) scenario (GET /scenarios/{id}/payload). */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ClientValidationError, DataikuError, nonJsonResponseBody, SQL_QUERY_FAILED_MARKER, } from "../errors.js";
|
|
1
|
+
import { ClientValidationError, DataikuError, nonJsonResponseBody, RESPONSE_TOO_LARGE_STATUS_TEXT, SQL_QUERY_FAILED_MARKER, } from "../errors.js";
|
|
2
2
|
import { asRecord, } from "../utils/records.js";
|
|
3
3
|
import { BaseResource, } from "./base.js";
|
|
4
4
|
const UNSUPPORTED_SQL_DATASET_CONNECTION_DETAIL = "neither of sql nor hdfs type";
|
|
@@ -192,7 +192,7 @@ export class SqlResource extends BaseResource {
|
|
|
192
192
|
const maxBytes = this.client.getMaxResponseBodyBytes();
|
|
193
193
|
const { text, truncated, } = await this.client.getTextLimited(`/public/api/sql/queries/${id}/stream?format=json`, maxBytes);
|
|
194
194
|
if (truncated) {
|
|
195
|
-
throw new DataikuError(0,
|
|
195
|
+
throw new DataikuError(0, RESPONSE_TOO_LARGE_STATUS_TEXT, `SQL stream results exceeded the ${maxBytes}-byte response limit and were not parsed.`);
|
|
196
196
|
}
|
|
197
197
|
try {
|
|
198
198
|
return JSON.parse(text);
|
|
@@ -210,7 +210,7 @@ export class SqlResource extends BaseResource {
|
|
|
210
210
|
const maxBytes = Math.min(this.client.getMaxResponseBodyBytes(), MAX_SQL_FINISH_TEXT_BYTES);
|
|
211
211
|
const { text, truncated, } = await this.client.getTextLimited(`/public/api/sql/queries/${id}/finish-streaming`, maxBytes);
|
|
212
212
|
if (truncated) {
|
|
213
|
-
throw new DataikuError(0,
|
|
213
|
+
throw new DataikuError(0, RESPONSE_TOO_LARGE_STATUS_TEXT, `SQL finish-streaming response exceeded the ${maxBytes}-byte response limit.`);
|
|
214
214
|
}
|
|
215
215
|
if (text.length > 0) {
|
|
216
216
|
throw new DataikuError(200, "SQL Query Failed", `${SQL_QUERY_FAILED_MARKER} (${queryId}): ${text}`);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { stat as statFile, } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { ClientValidationError, } from "../errors.js";
|
|
4
4
|
import { WikiArticleDataArraySchema, WikiArticleDataSchema, WikiSettingsSchema, } from "../schemas.js";
|
|
@@ -19,13 +19,9 @@ export function wikiAttachmentNotFound(data, smartId) {
|
|
|
19
19
|
}
|
|
20
20
|
/** Check that a local path is a readable regular file before any DSS call. */
|
|
21
21
|
export async function statWikiAttachmentFile(localPath) {
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
stat = await fs.promises.stat(localPath);
|
|
25
|
-
}
|
|
26
|
-
catch (error) {
|
|
22
|
+
const stat = await statFile(localPath).catch((error) => {
|
|
27
23
|
throw new ClientValidationError(`Could not read attachment file ${localPath}: ${error instanceof Error ? error.message : String(error)}.`, "not_found", "Verify the file path and that it is readable.");
|
|
28
|
-
}
|
|
24
|
+
});
|
|
29
25
|
if (!stat.isFile()) {
|
|
30
26
|
throw new ClientValidationError(`${localPath} is not a regular file.`);
|
|
31
27
|
}
|
package/dist/src/skill.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { fileURLToPath, } from "node:url";
|
|
5
4
|
import { ClientValidationError, } from "./errors.js";
|
|
6
5
|
import { sha256Hex, } from "./utils/stable-hash.js";
|
|
7
6
|
const SKILL_URLS = [
|
|
@@ -114,7 +113,7 @@ function skillState(target, expectedSha256) {
|
|
|
114
113
|
}
|
|
115
114
|
/** Load the packaged tree, keeping portable relative paths in the report. */
|
|
116
115
|
function skillFiles() {
|
|
117
|
-
const root = fileURLToPath(skillDirectory());
|
|
116
|
+
const root = Bun.fileURLToPath(skillDirectory());
|
|
118
117
|
const files = new Map();
|
|
119
118
|
function visit(relativeDir) {
|
|
120
119
|
const dir = path.join(root, relativeDir);
|
|
@@ -35,6 +35,25 @@ export interface ProjectArchiveIssue {
|
|
|
35
35
|
message: string;
|
|
36
36
|
member?: string;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* One credential-bearing structure found in an archive member. Carries the
|
|
40
|
+
* member path and the JSON key path / field name only, never the value.
|
|
41
|
+
* - `member_name`: the member itself is a credential store (for example
|
|
42
|
+
* `project_config/apikeys.json`) and is not empty.
|
|
43
|
+
* - `json_key`: a credential-named JSON key holds a non-empty string value
|
|
44
|
+
* (or a container of them), e.g. `$.standard.DHL_API_KEY` or a nested
|
|
45
|
+
* web-app `apiKey`.
|
|
46
|
+
* - `list_variable`: a list-form `{name, value}` entry whose `name` is
|
|
47
|
+
* credential-named, e.g. `{name: "X_API_KEY", value: "..."}`.
|
|
48
|
+
*/
|
|
49
|
+
export interface ProjectArchiveCredentialFinding {
|
|
50
|
+
member: string;
|
|
51
|
+
rule: "member_name" | "json_key" | "list_variable";
|
|
52
|
+
/** JSON path of the credential value inside the member (absent for `member_name`). */
|
|
53
|
+
keyPath?: string;
|
|
54
|
+
/** Credential-named key or list-form variable name (absent for `member_name`). */
|
|
55
|
+
field?: string;
|
|
56
|
+
}
|
|
38
57
|
export interface ProjectArchiveInspection {
|
|
39
58
|
filePath: string;
|
|
40
59
|
sizeBytes: number;
|
|
@@ -42,6 +61,12 @@ export interface ProjectArchiveInspection {
|
|
|
42
61
|
sourceProjectKey?: string;
|
|
43
62
|
valid: boolean;
|
|
44
63
|
issues: ProjectArchiveIssue[];
|
|
64
|
+
/**
|
|
65
|
+
* Credential inventory: member paths and key paths/field names of
|
|
66
|
+
* credential-bearing structures. Values are never included. Each finding
|
|
67
|
+
* is also summarized by a `credential_structure` warning issue.
|
|
68
|
+
*/
|
|
69
|
+
credentialFindings: ProjectArchiveCredentialFinding[];
|
|
45
70
|
}
|
|
46
71
|
/**
|
|
47
72
|
* Inspect a DSS project export ZIP archive without extracting anything to
|