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.
Files changed (74) hide show
  1. package/README.md +4 -3
  2. package/dist/build-metadata.json +1 -1
  3. package/dist/packages/types/src/index.d.ts +6 -0
  4. package/dist/packages/types/src/index.js +6 -0
  5. package/dist/src/cli/coerce.d.ts +6 -0
  6. package/dist/src/cli/coerce.js +9 -0
  7. package/dist/src/cli/command-syntax.json +216 -0
  8. package/dist/src/cli/commands/app.js +2 -2
  9. package/dist/src/cli/commands/code-env.js +10 -9
  10. package/dist/src/cli/commands/code.js +16 -2
  11. package/dist/src/cli/commands/dashboard.js +1 -2
  12. package/dist/src/cli/commands/dataset.js +6 -4
  13. package/dist/src/cli/commands/flow-zone.js +1 -1
  14. package/dist/src/cli/commands/job.js +70 -16
  15. package/dist/src/cli/commands/plugin.js +7 -7
  16. package/dist/src/cli/commands/project-library.js +2 -2
  17. package/dist/src/cli/commands/project.js +21 -4
  18. package/dist/src/cli/commands/recipe.js +34 -21
  19. package/dist/src/cli/commands/scenario.js +78 -12
  20. package/dist/src/cli/contract.js +30 -7
  21. package/dist/src/cli/env.d.ts +6 -0
  22. package/dist/src/cli/env.js +10 -3
  23. package/dist/src/cli/helpers/app-permissions.d.ts +1 -1
  24. package/dist/src/cli/helpers/app-permissions.js +2 -2
  25. package/dist/src/cli/helpers/cleanup.d.ts +2 -0
  26. package/dist/src/cli/helpers/cleanup.js +122 -8
  27. package/dist/src/cli/helpers/flow-zone.d.ts +1 -1
  28. package/dist/src/cli/helpers/flow-zone.js +3 -4
  29. package/dist/src/cli/list-output.d.ts +5 -0
  30. package/dist/src/cli/list-output.js +11 -2
  31. package/dist/src/cli/list-shapes.d.ts +6 -0
  32. package/dist/src/cli/list-shapes.js +7 -1
  33. package/dist/src/cli/output-schemas.d.ts +1 -1
  34. package/dist/src/cli/output-schemas.js +1 -1
  35. package/dist/src/cli/output.d.ts +2 -0
  36. package/dist/src/cli/output.js +31 -4
  37. package/dist/src/cli/read-shapes.js +90 -0
  38. package/dist/src/cli/result-shapes.d.ts +19 -1
  39. package/dist/src/cli/result-shapes.js +227 -4
  40. package/dist/src/cli/selector-suggestions.d.ts +14 -0
  41. package/dist/src/cli/selector-suggestions.js +56 -0
  42. package/dist/src/cli/usage.d.ts +1 -1
  43. package/dist/src/cli/usage.js +29 -4
  44. package/dist/src/cli/version.d.ts +2 -2
  45. package/dist/src/cli/version.js +4 -5
  46. package/dist/src/cli.js +128 -30
  47. package/dist/src/client.d.ts +15 -2
  48. package/dist/src/client.js +36 -11
  49. package/dist/src/errors.d.ts +2 -0
  50. package/dist/src/errors.js +12 -2
  51. package/dist/src/generated/action-output-schemas.json +232 -0
  52. package/dist/src/index.d.ts +1 -1
  53. package/dist/src/resources/datasets.d.ts +11 -1
  54. package/dist/src/resources/datasets.js +91 -30
  55. package/dist/src/resources/jobs.d.ts +35 -5
  56. package/dist/src/resources/jobs.js +68 -41
  57. package/dist/src/resources/projects.js +2 -2
  58. package/dist/src/resources/recipe-create.d.ts +4 -1
  59. package/dist/src/resources/recipe-create.js +11 -2
  60. package/dist/src/resources/recipes.d.ts +12 -1
  61. package/dist/src/resources/recipes.js +39 -12
  62. package/dist/src/resources/scenarios.d.ts +8 -1
  63. package/dist/src/resources/scenarios.js +51 -12
  64. package/dist/src/resources/sql.js +3 -3
  65. package/dist/src/resources/wiki.js +3 -7
  66. package/dist/src/skill.js +1 -2
  67. package/dist/src/utils/project-archive.d.ts +25 -0
  68. package/dist/src/utils/project-archive.js +216 -5
  69. package/dist/src/utils/response-file.js +15 -8
  70. package/package.json +12 -12
  71. package/plugin.json +1 -1
  72. package/skills/dataiku-dss/SKILL.md +15 -17
  73. package/skills/dataiku-dss/references/discovery.md +2 -2
  74. 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 and deadline-covered: at most `maxLogBytes`
326
- * (default 10 MiB) of the *most recent* log output is retained, so a
327
- * collaborator-influenced oversized log cannot exhaust client memory while
328
- * the line limit previously only applied after a full unbounded download.
329
- * Logs up to the cap are returned in full.
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
- return limitJobLog(filterJobLog(redactCredentialPairs(text), opts?.logFilter), opts?.maxLogLines);
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
- const bs = lastJ.baseStatus ?? {};
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
- return {
486
- success: false,
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
- return { ...payload, keys: config.keys.map((column) => ({ column, })), values, };
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
- const currentRecipe = asRecord(current.recipe);
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 misplacedRecipeFields = rootRecipeDefinitionFields(data);
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 writeFile(filePath, result.payload, "utf-8");
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 writeFile(filePath, JSON.stringify(recipe, null, 2), "utf-8");
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(_dku_buf.getvalue())",
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
- const messageRe = /^\[[^\]]*\] \[[^\]]*\] \[[^\]]*\] \[process\] - (.*)$/;
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
- return body.join("\n");
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`, { script: buildCodeRunScript(script), extension: "py", });
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 output = extractCodeRunOutput(log);
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 { text, truncated, } = await this.client.getTextLimited(`/public/api/projects/${this.enc(opts?.projectKey)}/scenarios/${scEnc}/${encodeURIComponent(runId)}/log${query}`, maxLogBytes);
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, "Response Too Large", `SQL stream results exceeded the ${maxBytes}-byte response limit and were not parsed.`);
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, "Response Too Large", `SQL finish-streaming response exceeded the ${maxBytes}-byte response limit.`);
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 fs from "node:fs";
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
- let stat;
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