dataiku-sdk 3.7.0 → 3.9.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 (62) hide show
  1. package/README.md +4 -2
  2. package/dist/build-metadata.json +1 -1
  3. package/dist/packages/types/src/index.d.ts +40 -4
  4. package/dist/packages/types/src/index.js +22 -1
  5. package/dist/src/cli/coerce.d.ts +1 -0
  6. package/dist/src/cli/coerce.js +1 -1
  7. package/dist/src/cli/command-syntax.json +156 -0
  8. package/dist/src/cli/commands/dataset.js +44 -8
  9. package/dist/src/cli/commands/flow-zone.js +241 -46
  10. package/dist/src/cli/commands/job.js +52 -9
  11. package/dist/src/cli/commands/project.js +4 -3
  12. package/dist/src/cli/commands/recipe.d.ts +4 -0
  13. package/dist/src/cli/commands/recipe.js +38 -1
  14. package/dist/src/cli/commands/wiki.js +56 -0
  15. package/dist/src/cli/contract.js +13 -8
  16. package/dist/src/cli/doctor.js +2 -0
  17. package/dist/src/cli/flags.d.ts +7 -0
  18. package/dist/src/cli/flags.js +28 -2
  19. package/dist/src/cli/helpers/dataset.d.ts +12 -0
  20. package/dist/src/cli/helpers/dataset.js +36 -0
  21. package/dist/src/cli/helpers/flow-zone.d.ts +21 -0
  22. package/dist/src/cli/helpers/flow-zone.js +144 -4
  23. package/dist/src/cli/output-schemas.js +27 -3
  24. package/dist/src/cli/output.js +6 -0
  25. package/dist/src/cli/plans.js +58 -2
  26. package/dist/src/cli/project-map-output.d.ts +24 -0
  27. package/dist/src/cli/project-map-output.js +21 -0
  28. package/dist/src/cli/read-shapes.d.ts +14 -0
  29. package/dist/src/cli/read-shapes.js +114 -0
  30. package/dist/src/cli/result-shapes.d.ts +6 -0
  31. package/dist/src/cli/result-shapes.js +235 -0
  32. package/dist/src/cli/types.d.ts +2 -0
  33. package/dist/src/cli/version.d.ts +2 -2
  34. package/dist/src/cli/version.js +2 -2
  35. package/dist/src/cli.js +23 -20
  36. package/dist/src/errors.d.ts +11 -1
  37. package/dist/src/errors.js +18 -3
  38. package/dist/src/generated/action-output-schemas.json +224 -35
  39. package/dist/src/resources/dataset-create.d.ts +19 -0
  40. package/dist/src/resources/dataset-create.js +69 -0
  41. package/dist/src/resources/datasets.d.ts +21 -0
  42. package/dist/src/resources/datasets.js +61 -1
  43. package/dist/src/resources/flow-zones.d.ts +29 -3
  44. package/dist/src/resources/flow-zones.js +53 -3
  45. package/dist/src/resources/jobs.d.ts +36 -1
  46. package/dist/src/resources/jobs.js +111 -5
  47. package/dist/src/resources/projects.d.ts +9 -0
  48. package/dist/src/resources/projects.js +10 -3
  49. package/dist/src/resources/recipe-create.d.ts +17 -3
  50. package/dist/src/resources/recipe-create.js +149 -33
  51. package/dist/src/resources/recipes.js +76 -23
  52. package/dist/src/resources/sql.js +52 -13
  53. package/dist/src/resources/wiki.d.ts +47 -1
  54. package/dist/src/resources/wiki.js +89 -0
  55. package/dist/src/utils/flow-analysis.js +4 -0
  56. package/dist/src/utils/secret-sanitize.d.ts +11 -2
  57. package/dist/src/utils/secret-sanitize.js +23 -6
  58. package/package.json +4 -4
  59. package/plugin.json +1 -1
  60. package/skills/dataiku-dss/references/discovery.md +2 -2
  61. package/skills/dataiku-dss/references/flow-maps.md +2 -2
  62. package/skills/dataiku-dss/references/troubleshooting.md +3 -3
@@ -55,6 +55,18 @@ export interface DatasetManagedCreateResult {
55
55
  projectKey: string;
56
56
  creationSettings: Record<string, unknown>;
57
57
  }
58
+ /** Outcome of {@link DatasetsResource.detectSqlSchema}. */
59
+ export interface DatasetSqlSchemaDetection {
60
+ /** True when DSS suggested a schema and it was stored on the dataset. */
61
+ detected: boolean;
62
+ /** Stored columns (empty when detection failed). */
63
+ columns: Array<{
64
+ name: string;
65
+ type: string;
66
+ }>;
67
+ /** Why detection failed. */
68
+ error?: string;
69
+ }
58
70
  export interface DatasetCloneOptions {
59
71
  projectKey?: string;
60
72
  path?: string;
@@ -106,6 +118,15 @@ export declare class DatasetsResource extends BaseResource {
106
118
  schema(datasetName: string, projectKey?: string): Promise<DatasetSchema>;
107
119
  /** Replace dataset schema columns directly through the schema endpoint. */
108
120
  updateSchema(datasetName: string, columns: DatasetSchemaColumnInput[], projectKey?: string): Promise<void>;
121
+ /**
122
+ * Detect the schema of a SQL dataset (table or query mode) with DSS and
123
+ * store it: `POST /actions/testAndDetectSettings/externalSQL` (what
124
+ * dataikuapi's `autodetect_settings` calls), then `PUT /schema` with the
125
+ * suggested schema. DSS reports connection, table, and query problems in the
126
+ * response body (HTTP 200), so a failed detection is returned with
127
+ * `detected: false` and the reason instead of thrown.
128
+ */
129
+ detectSqlSchema(datasetName: string, projectKey?: string): Promise<DatasetSqlSchemaDetection>;
109
130
  /**
110
131
  * Get the raw dataset schema object exactly as the DSS schema endpoint
111
132
  * returns it, without schema validation — used for deterministic comparison.
@@ -4,6 +4,7 @@ import { StringDecoder, } from "node:string_decoder";
4
4
  import { ClientValidationError, DataikuError, } from "../errors.js";
5
5
  import { DatasetDetailsSchema, DatasetSchemaSchema, DatasetSummaryArraySchema, } from "../schemas.js";
6
6
  import { deepMerge, } from "../utils/deep-merge.js";
7
+ import { isRecord, } from "../utils/records.js";
7
8
  import { sanitizeFileName, } from "../utils/sanitize.js";
8
9
  import { BaseResource, } from "./base.js";
9
10
  import { resolveAdminManagedStorageConnection, } from "./connections.js";
@@ -519,6 +520,28 @@ function cloneDatasetParams(params) {
519
520
  }
520
521
  return cloned;
521
522
  }
523
+ /** Explain an empty detection result from the first error DSS put in the response body. */
524
+ function describeSqlDetectionFailure(raw) {
525
+ if (!isRecord(raw))
526
+ return "DSS returned no schema detection result.";
527
+ for (const [key, value,] of Object.entries(raw)) {
528
+ if (!key.endsWith("Error"))
529
+ continue;
530
+ if (typeof value === "string" && value !== "")
531
+ return value;
532
+ if (isRecord(value)) {
533
+ const message = value["detailedMessage"] ?? value["message"];
534
+ if (typeof message === "string" && message !== "")
535
+ return message;
536
+ }
537
+ }
538
+ const failedChecks = Object.entries(raw)
539
+ .filter(([key, value,]) => key.endsWith("OK") && value === false)
540
+ .map(([key,]) => key);
541
+ return failedChecks.length > 0
542
+ ? `DSS detected no columns (failed checks: ${failedChecks.join(", ")}).`
543
+ : "DSS detected no columns.";
544
+ }
522
545
  function parseUploadedFiles(raw, datasetName) {
523
546
  if (!Array.isArray(raw)) {
524
547
  throw new ClientValidationError(`DSS returned an invalid uploaded-files list for dataset ${datasetName}.`, "validation_failed", "Expected an array from the UploadedFiles dataset file endpoint.");
@@ -592,6 +615,40 @@ export class DatasetsResource extends BaseResource {
592
615
  const dsEnc = encodeURIComponent(datasetName);
593
616
  await this.client.put(`/public/api/projects/${this.enc(projectKey)}/datasets/${dsEnc}/schema`, { columns, });
594
617
  }
618
+ /**
619
+ * Detect the schema of a SQL dataset (table or query mode) with DSS and
620
+ * store it: `POST /actions/testAndDetectSettings/externalSQL` (what
621
+ * dataikuapi's `autodetect_settings` calls), then `PUT /schema` with the
622
+ * suggested schema. DSS reports connection, table, and query problems in the
623
+ * response body (HTTP 200), so a failed detection is returned with
624
+ * `detected: false` and the reason instead of thrown.
625
+ */
626
+ async detectSqlSchema(datasetName, projectKey) {
627
+ const base = `/public/api/projects/${this.enc(projectKey)}/datasets/${encodeURIComponent(datasetName)}`;
628
+ try {
629
+ const raw = await this.client.post(`${base}/actions/testAndDetectSettings/externalSQL`);
630
+ const detection = isRecord(raw) ? raw["schemaDetection"] : undefined;
631
+ const newSchema = isRecord(detection) ? detection["newSchema"] : undefined;
632
+ const rawColumns = isRecord(newSchema) && Array.isArray(newSchema["columns"])
633
+ ? newSchema["columns"]
634
+ : [];
635
+ const columns = rawColumns.flatMap((column) => isRecord(column) && typeof column["name"] === "string"
636
+ && typeof column["type"] === "string"
637
+ ? [{ name: column["name"], type: column["type"], },]
638
+ : []);
639
+ if (!isRecord(newSchema) || columns.length === 0) {
640
+ return { detected: false, columns: [], error: describeSqlDetectionFailure(raw), };
641
+ }
642
+ await this.client.put(`${base}/schema`, newSchema);
643
+ return { detected: true, columns, };
644
+ }
645
+ catch (error) {
646
+ if (error instanceof DataikuError) {
647
+ return { detected: false, columns: [], error: error.message.split("\n")[0] ?? "", };
648
+ }
649
+ throw error;
650
+ }
651
+ }
595
652
  /**
596
653
  * Get the raw dataset schema object exactly as the DSS schema endpoint
597
654
  * returns it, without schema validation — used for deterministic comparison.
@@ -859,7 +916,7 @@ export class DatasetsResource extends BaseResource {
859
916
  const enc = encodeURIComponent(pk);
860
917
  const explicitType = opts.dsType;
861
918
  let dsType = explicitType
862
- ?? (opts.table ? DEFAULT_DATABASE_DATASET_TYPE : DEFAULT_FILESYSTEM_DATASET_TYPE);
919
+ ?? (opts.table || opts.query ? DEFAULT_DATABASE_DATASET_TYPE : DEFAULT_FILESYSTEM_DATASET_TYPE);
863
920
  let body = buildDatasetCreateBody({
864
921
  projectKey: pk,
865
922
  datasetName: opts.datasetName,
@@ -868,6 +925,7 @@ export class DatasetsResource extends BaseResource {
868
925
  table: opts.table,
869
926
  dbSchema: opts.dbSchema,
870
927
  catalog: opts.catalog,
928
+ query: opts.query,
871
929
  formatType: opts.formatType,
872
930
  formatParams: opts.formatParams,
873
931
  managed: opts.managed,
@@ -899,6 +957,7 @@ export class DatasetsResource extends BaseResource {
899
957
  table: opts.table,
900
958
  dbSchema: opts.dbSchema,
901
959
  catalog: opts.catalog,
960
+ query: opts.query,
902
961
  formatType: opts.formatType,
903
962
  formatParams: opts.formatParams,
904
963
  managed: opts.managed,
@@ -923,6 +982,7 @@ export class DatasetsResource extends BaseResource {
923
982
  table: opts.table,
924
983
  dbSchema: opts.dbSchema,
925
984
  catalog: opts.catalog,
985
+ query: opts.query,
926
986
  formatType: opts.formatType,
927
987
  formatParams: opts.formatParams,
928
988
  managed: opts.managed,
@@ -1,11 +1,21 @@
1
1
  import type { DataikuGetOptions } from "../client.js";
2
- import type { FlowZone, FlowZoneCreateOptions, FlowZoneObjectType, FlowZoneUpdateOptions } from "../schemas.js";
2
+ import type { FlowZone, FlowZoneCreateOptions, FlowZoneObjectType, FlowZonePosition, FlowZoneUpdateOptions } from "../schemas.js";
3
3
  import { BaseResource } from "./base.js";
4
4
  export type FlowZoneItemInput = {
5
5
  objectId: string;
6
6
  objectType: FlowZoneObjectType;
7
7
  projectKey?: string;
8
8
  };
9
+ export type FlowZoneRecreateOptions = {
10
+ position: FlowZonePosition;
11
+ name?: string;
12
+ color?: string;
13
+ projectKey?: string;
14
+ };
15
+ export type FlowZoneRecreateResult = {
16
+ zone: FlowZone;
17
+ replacedZoneId: string;
18
+ };
9
19
  export declare class FlowZonesResource extends BaseResource {
10
20
  /**
11
21
  * List all flow zones in a project. Pass `timeoutMs` to bound the whole
@@ -14,10 +24,26 @@ export declare class FlowZonesResource extends BaseResource {
14
24
  list(projectKey?: string, options?: DataikuGetOptions): Promise<FlowZone[]>;
15
25
  /** Get one flow zone by id. */
16
26
  get(zoneId: string, projectKey?: string): Promise<FlowZone>;
17
- /** Create a flow zone. */
27
+ /**
28
+ * Create a flow zone. DSS stores `position` only while the project's manual
29
+ * zone positioning is off; with it on, DSS auto-places the zone.
30
+ */
18
31
  create(opts: FlowZoneCreateOptions): Promise<FlowZone>;
19
- /** Update flow zone settings such as name, color, and manual position. */
32
+ /**
33
+ * Update flow zone name and color. DSS's public API ignores `position` on
34
+ * this endpoint; use {@link recreate} to move an existing zone.
35
+ */
20
36
  update(zoneId: string, opts: FlowZoneUpdateOptions): Promise<FlowZone>;
37
+ /**
38
+ * Replace a zone with a new one at `position`: DSS honors a zone position
39
+ * only on creation, and only while the project's manual zone positioning is
40
+ * off (otherwise it auto-places the new zone). The replacement receives the
41
+ * old zone's name, color, metadata (tags, custom fields, checklists, ...),
42
+ * items, and shared items; the old zone is deleted once it is empty. The zone
43
+ * id changes, so anything referencing the old id (for example scenario zone
44
+ * builds) must be updated.
45
+ */
46
+ recreate(zoneId: string, opts: FlowZoneRecreateOptions): Promise<FlowZoneRecreateResult>;
21
47
  /** Delete a flow zone. DSS moves its items back to the default zone. */
22
48
  delete(zoneId: string, projectKey?: string): Promise<void>;
23
49
  /** Move items into a flow zone. */
@@ -23,7 +23,10 @@ export class FlowZonesResource extends BaseResource {
23
23
  const raw = await this.client.get(`/public/api/projects/${this.enc(projectKey)}/flow/zones/${encodeURIComponent(zoneId)}`);
24
24
  return this.client.safeParse(FlowZoneSchema, raw, "flowZones.get");
25
25
  }
26
- /** Create a flow zone. */
26
+ /**
27
+ * Create a flow zone. DSS stores `position` only while the project's manual
28
+ * zone positioning is off; with it on, DSS auto-places the zone.
29
+ */
27
30
  async create(opts) {
28
31
  const raw = await this.client.post(`/public/api/projects/${this.enc(opts.projectKey)}/flow/zones`, {
29
32
  name: opts.name,
@@ -32,17 +35,64 @@ export class FlowZonesResource extends BaseResource {
32
35
  });
33
36
  return this.client.safeParse(FlowZoneSchema, raw, "flowZones.create");
34
37
  }
35
- /** Update flow zone settings such as name, color, and manual position. */
38
+ /**
39
+ * Update flow zone name and color. DSS's public API ignores `position` on
40
+ * this endpoint; use {@link recreate} to move an existing zone.
41
+ */
36
42
  async update(zoneId, opts) {
37
43
  const current = await this.get(zoneId, opts.projectKey);
38
44
  const merged = deepMerge(current, {
39
45
  ...(opts.name !== undefined ? { name: opts.name, } : {}),
40
46
  ...(opts.color !== undefined ? { color: opts.color, } : {}),
41
- ...(opts.position !== undefined ? { position: opts.position, } : {}),
42
47
  });
43
48
  await this.client.putVoid(`/public/api/projects/${this.enc(opts.projectKey)}/flow/zones/${encodeURIComponent(zoneId)}`, merged);
44
49
  return this.get(zoneId, opts.projectKey);
45
50
  }
51
+ /**
52
+ * Replace a zone with a new one at `position`: DSS honors a zone position
53
+ * only on creation, and only while the project's manual zone positioning is
54
+ * off (otherwise it auto-places the new zone). The replacement receives the
55
+ * old zone's name, color, metadata (tags, custom fields, checklists, ...),
56
+ * items, and shared items; the old zone is deleted once it is empty. The zone
57
+ * id changes, so anything referencing the old id (for example scenario zone
58
+ * builds) must be updated.
59
+ */
60
+ async recreate(zoneId, opts) {
61
+ const base = `/public/api/projects/${this.enc(opts.projectKey)}/flow/zones`;
62
+ const old = await this.get(zoneId, opts.projectKey);
63
+ const created = await this.create({
64
+ name: opts.name ?? old.name,
65
+ color: opts.color ?? old.color,
66
+ position: opts.position,
67
+ projectKey: opts.projectKey,
68
+ });
69
+ const completed = ["create",];
70
+ try {
71
+ const { id: _id, items, shared, position: _position, ...metadata } = old;
72
+ await this.client.putVoid(`${base}/${encodeURIComponent(created.id)}`, {
73
+ ...created,
74
+ ...metadata,
75
+ id: created.id,
76
+ name: created.name,
77
+ color: created.color,
78
+ position: created.position,
79
+ });
80
+ completed.push("metadata");
81
+ if (items && items.length > 0) {
82
+ await this.moveItems(created.id, items, opts.projectKey);
83
+ }
84
+ completed.push("items");
85
+ for (const item of shared ?? []) {
86
+ await this.client.post(`${base}/${encodeURIComponent(created.id)}/shared`, normalizeZoneItem(item));
87
+ }
88
+ completed.push("shared");
89
+ await this.delete(old.id, opts.projectKey);
90
+ }
91
+ catch (error) {
92
+ throw new ClientValidationError(`Recreating flow zone ${old.id} at a new position stopped after ${completed.at(-1)}; both zones may now exist.`, "ambiguous_outcome", "Inspect both zones with dss flow-zone list, then move remaining items and delete the zone you no longer need.", { replacedZoneId: old.id, zoneId: created.id, completed, }, { cause: error, });
93
+ }
94
+ return { zone: await this.get(created.id, opts.projectKey), replacedZoneId: old.id, };
95
+ }
46
96
  /** Delete a flow zone. DSS moves its items back to the default zone. */
47
97
  async delete(zoneId, projectKey) {
48
98
  await this.client.del(`/public/api/projects/${this.enc(projectKey)}/flow/zones/${encodeURIComponent(zoneId)}`);
@@ -23,12 +23,30 @@ export interface JobLogSummary {
23
23
  }
24
24
  /** Why a requested job log could not be retrieved. */
25
25
  export type JobLogUnavailableReason = "not_found" | "error";
26
+ /**
27
+ * A requested build target that no flow object can ever produce: a managed
28
+ * dataset with no recipe writing it.
29
+ */
30
+ export interface JobUnbuildableTarget {
31
+ type: "DATASET";
32
+ id: string;
33
+ projectKey: string;
34
+ reason: "no_producing_recipe";
35
+ }
36
+ /** Why a job that ended DONE is reported as failed because it built nothing. */
37
+ export interface JobNothingToBuildFailure {
38
+ code: "nothing_to_build";
39
+ message: string;
40
+ targets: JobUnbuildableTarget[];
41
+ }
26
42
  /** Result of a completed job wait, including log-unavailable metadata. */
27
43
  export type JobWaitOutcome = JobWaitResult & {
28
44
  logSummary?: JobLogSummary;
29
45
  logUnavailable?: JobLogUnavailableReason;
30
46
  /** True when the job itself no longer exists on the server. */
31
47
  removed?: boolean;
48
+ /** Set (with `success: false`) when a DONE job ran no activity and a target has no producer. */
49
+ failure?: JobNothingToBuildFailure;
32
50
  };
33
51
  export interface JobBuildTarget {
34
52
  id: string;
@@ -62,7 +80,8 @@ export declare class JobsResource extends BaseResource {
62
80
  /**
63
81
  * Retrieve job log text.
64
82
  * Returns the last `maxLogLines` lines (default 500) from the tail.
65
- * Use `0` or `-1` to return the log without line truncation.
83
+ * Use `0` or `-1` to return the log without line truncation. Credential-like
84
+ * JSON pairs (`"jobTicketSecret":"..."`) are redacted.
66
85
  *
67
86
  * The download is byte-bounded and deadline-covered: at most `maxLogBytes`
68
87
  * (default 10 MiB) of the *most recent* log output is retained, so a
@@ -120,6 +139,12 @@ export declare class JobsResource extends BaseResource {
120
139
  * confirmed gone. Explicit `includeLogs`/`summary` requests stay honest:
121
140
  * `log`/`logSummary` are only present when logs were actually retrieved.
122
141
  *
142
+ * A DONE job that ran no activity (`progress.total === 0`) is also what DSS
143
+ * reports for an already-up-to-date build, so it stays successful unless an
144
+ * output is a managed dataset that no recipe writes: then nothing can ever
145
+ * build it, and the outcome is `success: false` with
146
+ * `failure.code === "nothing_to_build"`.
147
+ *
123
148
  * On timeout, returns `{ success: false, ... }` rather than throwing.
124
149
  */
125
150
  wait(jobId: string, opts?: {
@@ -133,6 +158,16 @@ export declare class JobsResource extends BaseResource {
133
158
  timeoutMs?: number;
134
159
  projectKey?: string;
135
160
  }): Promise<JobWaitOutcome>;
161
+ /**
162
+ * Dataset outputs of a job that ran no activity which nothing can produce.
163
+ * DSS ends such a job DONE exactly like an already-up-to-date build, so the
164
+ * job status cannot tell them apart; the flow graph can: a target is
165
+ * unbuildable when it is a managed dataset (DSS owns its storage, so a
166
+ * recipe is the only way to fill it) that no recipe writes. Unmanaged
167
+ * datasets (uploads, external tables) are sources and legitimately build
168
+ * nothing. Lookups that fail leave the DONE outcome untouched.
169
+ */
170
+ private unbuildableTargets;
136
171
  /**
137
172
  * Probe whether a job still exists. Returns `true` when the server reports
138
173
  * it as not found, `false` when it is still visible, and `undefined` when
@@ -1,6 +1,8 @@
1
1
  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
+ import { asRecord, } from "../utils/records.js";
5
+ import { redactCredentialPairs, } from "../utils/secret-sanitize.js";
4
6
  import { BaseResource, } from "./base.js";
5
7
  const DEFAULT_MAX_LOG_LINES = 500;
6
8
  /**
@@ -103,6 +105,41 @@ function dssRecordMessage(line) {
103
105
  function isErrorContent(line) {
104
106
  return ERROR_KEYWORDS.test(line) || ERROR_CLASS_NAME.test(line) || PY_FILE_FRAME.test(line);
105
107
  }
108
+ /**
109
+ * Remove embedded JSON object payloads (`{"...": ...}`) from a DSS record
110
+ * line. Structured payloads such as `SQL status {"statusWarnLevel":"ERROR",...}`
111
+ * carry level/class words as data, not as a failure of the record itself.
112
+ * An unbalanced payload (truncated line) is dropped to the end of the line.
113
+ */
114
+ function stripJsonPayloads(line) {
115
+ let out = "";
116
+ let from = 0;
117
+ for (let start = line.indexOf('{"'); start !== -1; start = line.indexOf('{"', from)) {
118
+ out += line.slice(from, start);
119
+ let depth = 0;
120
+ let inString = false;
121
+ let end = line.length;
122
+ for (let i = start; i < line.length; i++) {
123
+ const ch = line[i];
124
+ if (inString) {
125
+ if (ch === "\\")
126
+ i++;
127
+ else if (ch === '"')
128
+ inString = false;
129
+ }
130
+ else if (ch === '"')
131
+ inString = true;
132
+ else if (ch === "{")
133
+ depth++;
134
+ else if (ch === "}" && --depth === 0) {
135
+ end = i + 1;
136
+ break;
137
+ }
138
+ }
139
+ from = end;
140
+ }
141
+ return out + line.slice(from);
142
+ }
106
143
  function lineMatchesLogFilter(line, filter) {
107
144
  const normalized = line.toLowerCase();
108
145
  switch (filter) {
@@ -159,7 +196,7 @@ function filterJobLogToErrors(lines) {
159
196
  pyTraceback = false;
160
197
  }
161
198
  if (isHeader) {
162
- recordMatches = isErrorContent(line) || PY_FILE_FRAME.test(message);
199
+ recordMatches = isErrorContent(stripJsonPayloads(line)) || PY_FILE_FRAME.test(message);
163
200
  if (recordMatches && (PY_TRACEBACK_HEAD.test(message) || PY_FILE_FRAME.test(message))) {
164
201
  pyTraceback = true;
165
202
  }
@@ -175,7 +212,7 @@ function filterJobLogToErrors(lines) {
175
212
  if (recordMatches
176
213
  && !JVM_STACK_FRAME.test(line)
177
214
  && !JVM_MORE_FRAMES.test(line)
178
- && isErrorContent(line))
215
+ && isErrorContent(stripJsonPayloads(line)))
179
216
  kept.push(line);
180
217
  }
181
218
  return kept.join("\n");
@@ -263,6 +300,10 @@ function summarizeJobLog(state, log, maxLines, elapsedMs) {
263
300
  ...(progress ? { progress, } : {}),
264
301
  };
265
302
  }
303
+ function nothingToBuildMessage(targets) {
304
+ const names = targets.map((target) => `${target.projectKey}.${target.id}`).join(", ");
305
+ return `The job ran no activity: ${names} ${targets.length === 1 ? "is a managed dataset" : "are managed datasets"} that no recipe produces, so nothing was built. Create a recipe that writes ${targets.length === 1 ? "it" : "them"} or build the recipe's output instead.`;
306
+ }
266
307
  export class JobsResource extends BaseResource {
267
308
  /** List jobs in a project. */
268
309
  async list(projectKey) {
@@ -278,7 +319,8 @@ export class JobsResource extends BaseResource {
278
319
  /**
279
320
  * Retrieve job log text.
280
321
  * Returns the last `maxLogLines` lines (default 500) from the tail.
281
- * Use `0` or `-1` to return the log without line truncation.
322
+ * Use `0` or `-1` to return the log without line truncation. Credential-like
323
+ * JSON pairs (`"jobTicketSecret":"..."`) are redacted.
282
324
  *
283
325
  * The download is byte-bounded and deadline-covered: at most `maxLogBytes`
284
326
  * (default 10 MiB) of the *most recent* log output is retained, so a
@@ -293,7 +335,9 @@ export class JobsResource extends BaseResource {
293
335
  const path = `/public/api/projects/${this.enc(opts?.projectKey)}/jobs/${jobEnc}/log/${query}`;
294
336
  const maxLogBytes = Math.max(1, opts?.maxLogBytes ?? DEFAULT_MAX_LOG_BYTES);
295
337
  const { text, } = await this.client.getTextTailLimited(path, maxLogBytes);
296
- return limitJobLog(filterJobLog(text, opts?.logFilter), opts?.maxLogLines);
338
+ // Single download choke point (logFromUrl and wait/summary go through here):
339
+ // DSS logs embed the job ticket secret in start_session JSON.
340
+ return limitJobLog(filterJobLog(redactCredentialPairs(text), opts?.logFilter), opts?.maxLogLines);
297
341
  }
298
342
  async logFromUrl(logUrl, opts) {
299
343
  const parsed = new URL(logUrl, "http://dss.local");
@@ -375,6 +419,12 @@ export class JobsResource extends BaseResource {
375
419
  * confirmed gone. Explicit `includeLogs`/`summary` requests stay honest:
376
420
  * `log`/`logSummary` are only present when logs were actually retrieved.
377
421
  *
422
+ * A DONE job that ran no activity (`progress.total === 0`) is also what DSS
423
+ * reports for an already-up-to-date build, so it stays successful unless an
424
+ * output is a managed dataset that no recipe writes: then nothing can ever
425
+ * build it, and the outcome is `success: false` with
426
+ * `failure.code === "nothing_to_build"`.
427
+ *
378
428
  * On timeout, returns `{ success: false, ... }` rather than throwing.
379
429
  */
380
430
  async wait(jobId, opts) {
@@ -492,8 +542,18 @@ export class JobsResource extends BaseResource {
492
542
  }
493
543
  }
494
544
  }
545
+ const unbuildable = success && gs.total === 0
546
+ ? await this.unbuildableTargets(def.outputs, def.projectKey ?? opts?.projectKey)
547
+ : [];
548
+ const failure = unbuildable.length > 0
549
+ ? {
550
+ code: "nothing_to_build",
551
+ message: nothingToBuildMessage(unbuildable),
552
+ targets: unbuildable,
553
+ }
554
+ : undefined;
495
555
  return {
496
- success,
556
+ success: success && failure === undefined,
497
557
  jobId: def.id ?? jobId,
498
558
  state,
499
559
  type: def.type ?? "unknown",
@@ -505,6 +565,7 @@ export class JobsResource extends BaseResource {
505
565
  running: gs.running ?? 0,
506
566
  total: gs.total ?? null,
507
567
  },
568
+ ...(failure !== undefined ? { failure, } : {}),
508
569
  ...(log !== undefined ? { log, } : {}),
509
570
  ...(logSummary !== undefined ? { logSummary, } : {}),
510
571
  ...(logUnavailable !== undefined ? { logUnavailable, } : {}),
@@ -523,6 +584,51 @@ export class JobsResource extends BaseResource {
523
584
  await sleep(Math.min(nextDelayMs, timeout - elapsedMs));
524
585
  }
525
586
  }
587
+ /**
588
+ * Dataset outputs of a job that ran no activity which nothing can produce.
589
+ * DSS ends such a job DONE exactly like an already-up-to-date build, so the
590
+ * job status cannot tell them apart; the flow graph can: a target is
591
+ * unbuildable when it is a managed dataset (DSS owns its storage, so a
592
+ * recipe is the only way to fill it) that no recipe writes. Unmanaged
593
+ * datasets (uploads, external tables) are sources and legitimately build
594
+ * nothing. Lookups that fail leave the DONE outcome untouched.
595
+ */
596
+ async unbuildableTargets(outputs, fallbackProjectKey) {
597
+ if (!Array.isArray(outputs))
598
+ return [];
599
+ const writtenByProject = new Map();
600
+ const unbuildable = [];
601
+ for (const output of outputs) {
602
+ const record = asRecord(output);
603
+ if (record?.type !== "DATASET" || typeof record.targetDataset !== "string")
604
+ continue;
605
+ const id = record.targetDataset;
606
+ const projectKey = typeof record.targetDatasetProjectKey === "string"
607
+ ? record.targetDatasetProjectKey
608
+ : fallbackProjectKey;
609
+ if (projectKey === undefined)
610
+ continue;
611
+ try {
612
+ const details = await this.client.datasets.get(id, projectKey);
613
+ if (details.managed !== true)
614
+ continue;
615
+ let written = writtenByProject.get(projectKey);
616
+ if (written === undefined) {
617
+ const { graph, } = await this.client.projects.flowTopology(projectKey);
618
+ written = new Set(graph.edges.filter((edge) => edge.relation === "writes").map((edge) => edge.to));
619
+ writtenByProject.set(projectKey, written);
620
+ }
621
+ if (!written.has(id)) {
622
+ unbuildable.push({ type: "DATASET", id, projectKey, reason: "no_producing_recipe", });
623
+ }
624
+ }
625
+ catch (error) {
626
+ if (!(error instanceof DataikuError || error instanceof ClientValidationError))
627
+ throw error;
628
+ }
629
+ }
630
+ return unbuildable;
631
+ }
526
632
  /**
527
633
  * Probe whether a job still exists. Returns `true` when the server reports
528
634
  * it as not found, `false` when it is still visible, and `undefined` when
@@ -1,5 +1,6 @@
1
1
  import type { FlowMapOptions, ProjectDetails, ProjectMetadata, ProjectSummary, ProjectTags } from "../schemas.js";
2
2
  import type { AnalyzedFlowMap, FlowMapRendering } from "../utils/flow-analysis.js";
3
+ import type { NormalizedFlowMap } from "../utils/flow-map.js";
3
4
  import { BaseResource } from "./base.js";
4
5
  interface FlowMapTruncationSummary {
5
6
  truncated: boolean;
@@ -150,6 +151,14 @@ export declare class ProjectsResource extends BaseResource {
150
151
  setTags(projectKey: string | undefined, tags: ProjectTags): Promise<void>;
151
152
  /** Get the raw flow graph for a project. */
152
153
  flow(projectKey?: string): Promise<unknown>;
154
+ /**
155
+ * Normalized flow topology (nodes and edges, no metadata lookups) plus its
156
+ * stable fingerprint, excluding visual metadata.
157
+ */
158
+ flowTopology(projectKey?: string): Promise<{
159
+ graph: NormalizedFlowMap;
160
+ topologyFingerprint: string;
161
+ }>;
153
162
  /** Compute a stable fingerprint of flow nodes and edges, excluding visual metadata. */
154
163
  topologyFingerprint(projectKey?: string): Promise<string>;
155
164
  /**
@@ -340,11 +340,18 @@ export class ProjectsResource extends BaseResource {
340
340
  const enc = this.enc(projectKey);
341
341
  return this.client.get(`/public/api/projects/${enc}/flow/graph/`);
342
342
  }
343
+ /**
344
+ * Normalized flow topology (nodes and edges, no metadata lookups) plus its
345
+ * stable fingerprint, excluding visual metadata.
346
+ */
347
+ async flowTopology(projectKey) {
348
+ const pk = this.resolveProjectKey(projectKey);
349
+ const graph = normalizeFlowGraph(await this.flow(pk), pk);
350
+ return { graph, topologyFingerprint: flowTopologyFingerprint(graph), };
351
+ }
343
352
  /** Compute a stable fingerprint of flow nodes and edges, excluding visual metadata. */
344
353
  async topologyFingerprint(projectKey) {
345
- const pk = this.resolveProjectKey(projectKey);
346
- const rawGraph = await this.flow(pk);
347
- return flowTopologyFingerprint(normalizeFlowGraph(rawGraph, pk));
354
+ return (await this.flowTopology(projectKey)).topologyFingerprint;
348
355
  }
349
356
  /**
350
357
  * Build a normalized, optionally truncated flow map for a project.
@@ -33,12 +33,26 @@ export declare function groupingPayloadConfig(opts: RecipeCreateOptions): Groupi
33
33
  * aggregate flags (and `orderColumn` for first/last).
34
34
  */
35
35
  export declare function applyGroupingPayload(payload: Record<string, unknown>, config: GroupingPayloadConfig): Record<string, unknown>;
36
+ /** Prepare option groups, in the default step order. */
37
+ export declare const PREPARE_STEP_KINDS: readonly ["rename", "fillEmpty", "formula", "filter", "dropColumns", "keepColumns"];
38
+ export type PrepareStepKind = (typeof PREPARE_STEP_KINDS)[number];
36
39
  /**
37
- * Prepare (shaker) steps from create options, in a fixed order: rename,
38
- * fill empty, formula column, row filter, drop columns, keep columns. Later
39
- * steps see renamed columns. Empty when no prepare option was given.
40
+ * Prepare (shaker) steps from create options: rename, fill empty, formula
41
+ * column, row filter, drop columns, keep columns. They run in `stepOrder`
42
+ * (flag order on the CLI), defaulting to that sequence, so a step sees the
43
+ * columns the steps before it produced. Empty when no prepare option was given.
40
44
  */
41
45
  export declare function prepareSteps(opts: RecipeCreateOptions): Record<string, unknown>[];
46
+ export interface RecipeCreateWarning {
47
+ code: string;
48
+ [key: string]: unknown;
49
+ }
50
+ /**
51
+ * Warnings about the prepare options that DSS cannot report itself: a
52
+ * `--formula`/`--filter` that runs after a `--rename` but still names a
53
+ * column that rename removed (it would fail or read an empty column).
54
+ */
55
+ export declare function recipeCreateWarnings(opts: RecipeCreateOptions): RecipeCreateWarning[];
42
56
  /**
43
57
  * Pure request construction for POST /recipes/ (no DSS calls). Shared with
44
58
  * `recipe create --plan` so the plan equals the request the command sends.