dataiku-sdk 3.8.0 → 4.0.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 (68) hide show
  1. package/README.md +7 -5
  2. package/dist/build-metadata.json +1 -1
  3. package/dist/packages/types/src/index.d.ts +4 -0
  4. package/dist/packages/types/src/index.js +4 -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 +36 -0
  8. package/dist/src/cli/commands/app.js +2 -2
  9. package/dist/src/cli/commands/code-env.js +5 -6
  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 +2 -2
  13. package/dist/src/cli/commands/flow-zone.js +1 -1
  14. package/dist/src/cli/commands/job.js +101 -22
  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 +25 -7
  18. package/dist/src/cli/commands/recipe.js +11 -19
  19. package/dist/src/cli/commands/scenario.js +2 -4
  20. package/dist/src/cli/contract.js +29 -10
  21. package/dist/src/cli/env.js +2 -3
  22. package/dist/src/cli/helpers/app-permissions.d.ts +1 -1
  23. package/dist/src/cli/helpers/app-permissions.js +2 -2
  24. package/dist/src/cli/helpers/cleanup.d.ts +2 -0
  25. package/dist/src/cli/helpers/cleanup.js +122 -8
  26. package/dist/src/cli/helpers/flow-zone.d.ts +1 -1
  27. package/dist/src/cli/helpers/flow-zone.js +3 -4
  28. package/dist/src/cli/list-output.d.ts +5 -0
  29. package/dist/src/cli/list-output.js +11 -2
  30. package/dist/src/cli/list-shapes.d.ts +6 -0
  31. package/dist/src/cli/list-shapes.js +7 -1
  32. package/dist/src/cli/output-schemas.d.ts +1 -1
  33. package/dist/src/cli/output-schemas.js +28 -4
  34. package/dist/src/cli/output.js +7 -0
  35. package/dist/src/cli/project-map-output.d.ts +24 -0
  36. package/dist/src/cli/project-map-output.js +21 -0
  37. package/dist/src/cli/read-shapes.d.ts +14 -0
  38. package/dist/src/cli/read-shapes.js +114 -0
  39. package/dist/src/cli/result-shapes.d.ts +6 -0
  40. package/dist/src/cli/result-shapes.js +235 -0
  41. package/dist/src/cli/version.d.ts +2 -2
  42. package/dist/src/cli/version.js +4 -5
  43. package/dist/src/cli.js +77 -33
  44. package/dist/src/client.d.ts +14 -2
  45. package/dist/src/client.js +18 -10
  46. package/dist/src/generated/action-output-schemas.json +242 -35
  47. package/dist/src/index.d.ts +1 -1
  48. package/dist/src/resources/datasets.d.ts +11 -1
  49. package/dist/src/resources/datasets.js +91 -30
  50. package/dist/src/resources/jobs.d.ts +37 -6
  51. package/dist/src/resources/jobs.js +110 -44
  52. package/dist/src/resources/projects.js +2 -2
  53. package/dist/src/resources/recipes.d.ts +12 -1
  54. package/dist/src/resources/recipes.js +39 -12
  55. package/dist/src/resources/scenarios.d.ts +8 -1
  56. package/dist/src/resources/scenarios.js +51 -12
  57. package/dist/src/resources/wiki.js +3 -7
  58. package/dist/src/skill.js +1 -2
  59. package/dist/src/utils/project-archive.d.ts +25 -0
  60. package/dist/src/utils/project-archive.js +216 -5
  61. package/dist/src/utils/response-file.js +15 -8
  62. package/dist/src/utils/secret-sanitize.d.ts +11 -2
  63. package/dist/src/utils/secret-sanitize.js +23 -6
  64. package/package.json +13 -14
  65. package/plugin.json +1 -1
  66. package/skills/dataiku-dss/SKILL.md +1 -1
  67. package/skills/dataiku-dss/references/discovery.md +2 -2
  68. package/skills/dataiku-dss/references/troubleshooting.md +2 -2
@@ -9,6 +9,20 @@ import { BaseResource } from "./base.js";
9
9
  */
10
10
  export type JobBuildTargetType = "DATASET" | "MANAGED_FOLDER" | "MODEL_EVALUATION_STORE";
11
11
  export type JobLogFilter = "stdout" | "stderr" | "user" | "errors";
12
+ /** A retrieved job log plus what the byte-bounded download dropped or scanned. */
13
+ export interface JobLogDetails {
14
+ /** Redacted, filtered and line-limited log text (what {@link JobsResource.log} returns). */
15
+ text: string;
16
+ /**
17
+ * True when the log exceeded the byte cap and only its most recent bytes
18
+ * were retained: earlier lines were never scanned or filtered.
19
+ */
20
+ truncated: boolean;
21
+ /** Lines in the retained log before filtering and the line limit. */
22
+ scannedLines: number;
23
+ /** Byte cap applied to the download (the most recent this-many bytes are kept). */
24
+ maxLogBytes: number;
25
+ }
12
26
  export interface JobLogProgress {
13
27
  lastProgressLine?: string;
14
28
  doneLine?: string;
@@ -80,13 +94,17 @@ export declare class JobsResource extends BaseResource {
80
94
  /**
81
95
  * Retrieve job log text.
82
96
  * Returns the last `maxLogLines` lines (default 500) from the tail.
83
- * Use `0` or `-1` to return the log without line truncation.
97
+ * Use `0` or `-1` to return the log without line truncation. Credential-like
98
+ * JSON pairs (`"jobTicketSecret":"..."`) are redacted.
84
99
  *
85
- * The download is byte-bounded and deadline-covered: at most `maxLogBytes`
86
- * (default 10 MiB) of the *most recent* log output is retained, so a
87
- * collaborator-influenced oversized log cannot exhaust client memory while
88
- * the line limit previously only applied after a full unbounded download.
89
- * Logs up to the cap are returned in full.
100
+ * The download is byte-bounded: at most `maxLogBytes` (default 10 MiB) of
101
+ * the *most recent* log output is retained, so a collaborator-influenced
102
+ * oversized log cannot exhaust client memory while the line limit
103
+ * previously only applied after a full unbounded download. The request
104
+ * timeout is a stall deadline (per received chunk), so a large log that
105
+ * keeps arriving is not cut off by a total-transfer budget. Logs up to the
106
+ * cap are returned in full. Use {@link logDetails} to learn whether bytes
107
+ * were dropped.
90
108
  */
91
109
  log(jobId: string, opts?: {
92
110
  activity?: string;
@@ -96,6 +114,19 @@ export declare class JobsResource extends BaseResource {
96
114
  maxLogBytes?: number;
97
115
  projectKey?: string;
98
116
  }): Promise<string>;
117
+ /**
118
+ * Same retrieval as {@link log}, additionally reporting whether the
119
+ * byte-bounded download dropped early log bytes (`truncated`) and how many
120
+ * lines were scanned before filtering (`scannedLines`).
121
+ */
122
+ logDetails(jobId: string, opts?: {
123
+ activity?: string;
124
+ logId?: string;
125
+ logFilter?: JobLogFilter;
126
+ maxLogLines?: number;
127
+ maxLogBytes?: number;
128
+ projectKey?: string;
129
+ }): Promise<JobLogDetails>;
99
130
  logFromUrl(logUrl: string, opts?: {
100
131
  maxLogLines?: number;
101
132
  maxLogBytes?: number;
@@ -2,6 +2,7 @@ import { ClientValidationError, DataikuError, } from "../errors.js";
2
2
  import { JobSummaryArraySchema, } from "../schemas.js";
3
3
  import { computeNextPollDelayMs, DEFAULT_POLL_INTERVAL_MS, DEFAULT_TIMEOUT_MS, isRequestDeadlineError, } from "../utils/polling.js";
4
4
  import { asRecord, } from "../utils/records.js";
5
+ import { redactCredentialPairs, } from "../utils/secret-sanitize.js";
5
6
  import { BaseResource, } from "./base.js";
6
7
  const DEFAULT_MAX_LOG_LINES = 500;
7
8
  /**
@@ -65,6 +66,12 @@ function jobBuildDefinition(targets, defaultProjectKey, opts) {
65
66
  function jobLogLines(log) {
66
67
  return log.split(/\r?\n/).map((line) => line.trimEnd());
67
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
+ }
68
75
  /**
69
76
  * DSS tags recipe subprocess output by reader thread: `[null-out-N]` carries
70
77
  * the child's stdout and `[null-err-N]` its stderr. The legacy fallback is an
@@ -80,6 +87,13 @@ const LEGACY_STDERR_RECORD = /^\s*stderr:/i;
80
87
  * continuation of that record (exception chain, JVM frames, ...).
81
88
  */
82
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/;
83
97
  const ERROR_KEYWORDS = /\b(error|failed|failure|exception|traceback)\b/i;
84
98
  /**
85
99
  * Exception class names ending in the `Exception`/`Error` suffix (e.g.
@@ -104,6 +118,41 @@ function dssRecordMessage(line) {
104
118
  function isErrorContent(line) {
105
119
  return ERROR_KEYWORDS.test(line) || ERROR_CLASS_NAME.test(line) || PY_FILE_FRAME.test(line);
106
120
  }
121
+ /**
122
+ * Remove embedded JSON object payloads (`{"...": ...}`) from a DSS record
123
+ * line. Structured payloads such as `SQL status {"statusWarnLevel":"ERROR",...}`
124
+ * carry level/class words as data, not as a failure of the record itself.
125
+ * An unbalanced payload (truncated line) is dropped to the end of the line.
126
+ */
127
+ function stripJsonPayloads(line) {
128
+ let out = "";
129
+ let from = 0;
130
+ for (let start = line.indexOf('{"'); start !== -1; start = line.indexOf('{"', from)) {
131
+ out += line.slice(from, start);
132
+ let depth = 0;
133
+ let inString = false;
134
+ let end = line.length;
135
+ for (let i = start; i < line.length; i++) {
136
+ const ch = line[i];
137
+ if (inString) {
138
+ if (ch === "\\")
139
+ i++;
140
+ else if (ch === '"')
141
+ inString = false;
142
+ }
143
+ else if (ch === '"')
144
+ inString = true;
145
+ else if (ch === "{")
146
+ depth++;
147
+ else if (ch === "}" && --depth === 0) {
148
+ end = i + 1;
149
+ break;
150
+ }
151
+ }
152
+ from = end;
153
+ }
154
+ return out + line.slice(from);
155
+ }
107
156
  function lineMatchesLogFilter(line, filter) {
108
157
  const normalized = line.toLowerCase();
109
158
  switch (filter) {
@@ -113,7 +162,7 @@ function lineMatchesLogFilter(line, filter) {
113
162
  case "stderr":
114
163
  return normalized.includes("[null-err-") || LEGACY_STDERR_RECORD.test(line);
115
164
  case "errors":
116
- return isErrorContent(line);
165
+ return isErrorContent(line) || JOB_OUTCOME_LINE.test(line);
117
166
  case "user":
118
167
  return !/^\d{4}[-/]\d{2}[-/]\d{2}/.test(line)
119
168
  && !normalized.includes("backend-log")
@@ -159,8 +208,13 @@ function filterJobLogToErrors(lines) {
159
208
  }
160
209
  pyTraceback = false;
161
210
  }
211
+ if (JOB_OUTCOME_LINE.test(line)) {
212
+ kept.push(line);
213
+ recordMatches = false;
214
+ continue;
215
+ }
162
216
  if (isHeader) {
163
- recordMatches = isErrorContent(line) || PY_FILE_FRAME.test(message);
217
+ recordMatches = isErrorContent(stripJsonPayloads(line)) || PY_FILE_FRAME.test(message);
164
218
  if (recordMatches && (PY_TRACEBACK_HEAD.test(message) || PY_FILE_FRAME.test(message))) {
165
219
  pyTraceback = true;
166
220
  }
@@ -176,7 +230,7 @@ function filterJobLogToErrors(lines) {
176
230
  if (recordMatches
177
231
  && !JVM_STACK_FRAME.test(line)
178
232
  && !JVM_MORE_FRAMES.test(line)
179
- && isErrorContent(line))
233
+ && isErrorContent(stripJsonPayloads(line)))
180
234
  kept.push(line);
181
235
  }
182
236
  return kept.join("\n");
@@ -283,22 +337,42 @@ export class JobsResource extends BaseResource {
283
337
  /**
284
338
  * Retrieve job log text.
285
339
  * Returns the last `maxLogLines` lines (default 500) from the tail.
286
- * Use `0` or `-1` to return the log without line truncation.
340
+ * Use `0` or `-1` to return the log without line truncation. Credential-like
341
+ * JSON pairs (`"jobTicketSecret":"..."`) are redacted.
287
342
  *
288
- * The download is byte-bounded and deadline-covered: at most `maxLogBytes`
289
- * (default 10 MiB) of the *most recent* log output is retained, so a
290
- * collaborator-influenced oversized log cannot exhaust client memory while
291
- * the line limit previously only applied after a full unbounded download.
292
- * 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.
293
351
  */
294
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) {
295
361
  const jobEnc = encodeURIComponent(jobId);
296
362
  const query = opts?.activity ? `?activity=${encodeURIComponent(opts.activity)}` : "";
297
363
  // DSS cat-activity-log URLs require a browser session; API-key callers must use the public log endpoint.
298
364
  const path = `/public/api/projects/${this.enc(opts?.projectKey)}/jobs/${jobEnc}/log/${query}`;
299
365
  const maxLogBytes = Math.max(1, opts?.maxLogBytes ?? DEFAULT_MAX_LOG_BYTES);
300
- const { text, } = await this.client.getTextTailLimited(path, maxLogBytes);
301
- return limitJobLog(filterJobLog(text, opts?.logFilter), opts?.maxLogLines);
366
+ const { text, truncated, } = await this.client.getTextTailLimited(path, maxLogBytes);
367
+ // Single download choke point (logFromUrl and wait/summary go through here):
368
+ // DSS logs embed the job ticket secret in start_session JSON.
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
+ };
302
376
  }
303
377
  async logFromUrl(logUrl, opts) {
304
378
  const parsed = new URL(logUrl, "http://dss.local");
@@ -399,6 +473,28 @@ export class JobsResource extends BaseResource {
399
473
  const startedAt = Date.now();
400
474
  let pollCount = 0;
401
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
+ };
402
498
  while (true) {
403
499
  const elapsedBeforeMs = Date.now() - startedAt;
404
500
  // The first observation always happens, even when the budget is
@@ -406,24 +502,7 @@ export class JobsResource extends BaseResource {
406
502
  // the loop reports the structured timeout from the last observed
407
503
  // state instead of issuing a request the budget cannot cover.
408
504
  if (lastJ !== undefined && elapsedBeforeMs >= timeout) {
409
- const bs = lastJ.baseStatus ?? {};
410
- const def = bs.def ?? {};
411
- const gs = lastJ.globalState ?? {};
412
- return {
413
- success: false,
414
- jobId,
415
- state: bs.state ?? "unknown",
416
- type: def.type ?? "unknown",
417
- elapsedMs: elapsedBeforeMs,
418
- pollCount,
419
- timedOut: true,
420
- progress: {
421
- done: gs.done ?? 0,
422
- failed: gs.failed ?? 0,
423
- running: gs.running ?? 0,
424
- total: gs.total ?? null,
425
- },
426
- };
505
+ return timedOutResult(lastJ, elapsedBeforeMs);
427
506
  }
428
507
  pollCount += 1;
429
508
  // Requests issued while budget remains are bounded by the remaining
@@ -443,21 +522,8 @@ export class JobsResource extends BaseResource {
443
522
  // error escape the loop.
444
523
  if (!isRequestDeadlineError(error, startedAt + timeout))
445
524
  throw error;
446
- return {
447
- success: false,
448
- jobId,
449
- state: "unknown",
450
- type: "unknown",
451
- elapsedMs: Date.now() - startedAt,
452
- pollCount,
453
- timedOut: true,
454
- progress: {
455
- done: 0,
456
- failed: 0,
457
- running: 0,
458
- total: null,
459
- },
460
- };
525
+ // A deadline mid-poll keeps the last state a previous poll observed.
526
+ return timedOutResult(lastJ, Date.now() - startedAt);
461
527
  }
462
528
  lastJ = j;
463
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
  }
@@ -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 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);