dataiku-sdk 4.1.0 → 4.2.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 (46) hide show
  1. package/dist/build-metadata.json +1 -1
  2. package/dist/packages/types/src/index.d.ts +2 -0
  3. package/dist/packages/types/src/index.js +2 -0
  4. package/dist/src/cli/command-syntax.json +2426 -1262
  5. package/dist/src/cli/commands/future.js +7 -2
  6. package/dist/src/cli/commands/index.js +4 -0
  7. package/dist/src/cli/commands/messaging-channel.d.ts +17 -0
  8. package/dist/src/cli/commands/messaging-channel.js +385 -0
  9. package/dist/src/cli/commands/project-deployer.js +33 -0
  10. package/dist/src/cli/commands/project.js +30 -0
  11. package/dist/src/cli/commands/scenario.d.ts +6 -0
  12. package/dist/src/cli/commands/scenario.js +423 -13
  13. package/dist/src/cli/contract.js +17 -2
  14. package/dist/src/cli/flags.js +34 -0
  15. package/dist/src/cli/helpers/cleanup.js +11 -0
  16. package/dist/src/cli/list-shapes.js +5 -0
  17. package/dist/src/cli/plans.js +94 -2
  18. package/dist/src/cli/read-shapes.js +2 -1
  19. package/dist/src/cli/result-shapes.js +1 -0
  20. package/dist/src/cli/usage.js +7 -0
  21. package/dist/src/cli/version.d.ts +2 -2
  22. package/dist/src/cli/version.js +2 -2
  23. package/dist/src/client.d.ts +11 -0
  24. package/dist/src/client.js +21 -2
  25. package/dist/src/generated/action-output-schemas.json +82 -0
  26. package/dist/src/index.d.ts +1 -0
  27. package/dist/src/index.js +1 -0
  28. package/dist/src/resources/bundles.d.ts +18 -0
  29. package/dist/src/resources/bundles.js +26 -0
  30. package/dist/src/resources/futures.d.ts +3 -0
  31. package/dist/src/resources/futures.js +5 -1
  32. package/dist/src/resources/messaging-channels.d.ts +83 -0
  33. package/dist/src/resources/messaging-channels.js +108 -0
  34. package/dist/src/resources/projects.d.ts +24 -0
  35. package/dist/src/resources/projects.js +36 -0
  36. package/dist/src/resources/scenario-authoring.d.ts +52 -0
  37. package/dist/src/resources/scenario-authoring.js +216 -0
  38. package/dist/src/resources/scenarios.d.ts +128 -11
  39. package/dist/src/resources/scenarios.js +273 -43
  40. package/dist/src/utils/messaging-channel-secrets.d.ts +17 -0
  41. package/dist/src/utils/messaging-channel-secrets.js +114 -0
  42. package/package.json +1 -1
  43. package/plugin.json +1 -1
  44. package/skills/dataiku-dss/SKILL.md +4 -3
  45. package/skills/dataiku-dss/references/notifications.md +29 -0
  46. package/skills/dataiku-dss/references/troubleshooting.md +1 -1
@@ -4,16 +4,20 @@ import { deepMerge, } from "../utils/deep-merge.js";
4
4
  import { computeNextPollDelayMs, isRequestDeadlineError, } from "../utils/polling.js";
5
5
  import { isRecord, } from "../utils/records.js";
6
6
  import { BaseResource, requireArrayResponse, requireNonEmpty, } from "./base.js";
7
+ import { selectScenarioListIndex, } from "./scenario-authoring.js";
8
+ /** DSS keeps triggers and reporters at the top level of the scenario (as dataikuapi does), not under params. */
7
9
  export const SCENARIO_CANONICAL_EDITABLE_FIELDS = [
8
10
  "params.steps",
9
- "params.triggers",
10
- "params.reporters",
11
+ "triggers",
12
+ "reporters",
11
13
  "params.customScript",
12
14
  "active",
13
15
  "name",
14
16
  ];
15
17
  /** Default byte cap applied when fetching a scenario run log. */
16
18
  export const DEFAULT_SCENARIO_MAX_LOG_BYTES = 1_048_576;
19
+ /** Where each list lives on the scenario object (top level, as dataikuapi). */
20
+ const SCENARIO_LIST_FIELDS = { trigger: "triggers", reporter: "reporters", };
17
21
  /**
18
22
  * Wraps a primary code-run failure so the original error (and its taxonomy:
19
23
  * category, exit-code semantics) stays intact while the concurrent throwaway
@@ -48,11 +52,55 @@ function triggerRunIdOf(trigger, scenarioId) {
48
52
  }
49
53
  return runId;
50
54
  }
51
- function triggerQueryOf(trigger) {
52
- const triggerObj = trigger.trigger;
53
- const triggerId = triggerObj?.id ?? "manual";
54
- const triggerRunId = triggerRunIdOf(trigger);
55
- return `triggerId=${encodeURIComponent(triggerId)}&triggerRunId=${encodeURIComponent(triggerRunId)}`;
55
+ /** Trigger id (`manual` for API runs), trigger run id and cancellation flag of a POST /run response. */
56
+ function triggerFireOf(trigger, scenarioId) {
57
+ const triggerId = isRecord(trigger.trigger) && typeof trigger.trigger.id === "string"
58
+ ? trigger.trigger.id
59
+ : "manual";
60
+ return {
61
+ triggerId,
62
+ triggerRunId: triggerRunIdOf(trigger, scenarioId),
63
+ cancelled: trigger.cancelled === true,
64
+ };
65
+ }
66
+ /** The `triggers`/`reporters` array of a scenario object; absent means empty, any other shape is an error. */
67
+ function scenarioListField(scenario, kind) {
68
+ const items = scenario[SCENARIO_LIST_FIELDS[kind]];
69
+ return items === undefined ? [] : requireArrayResponse(items, `scenario ${kind}s`);
70
+ }
71
+ /**
72
+ * DSS stamps `params.lastUpdate` on every temporal trigger each time the scenario
73
+ * is saved, so echoing the value it last stored can never be read back (the
74
+ * verification after a trigger edit would report every trigger as unpersisted).
75
+ * Omitting it lets DSS stamp afresh, as it does for the other saved triggers.
76
+ */
77
+ function withoutServerStamp(item) {
78
+ if (!isRecord(item) || !isRecord(item.params) || !("lastUpdate" in item.params))
79
+ return item;
80
+ const { lastUpdate: _stamp, ...params } = item.params;
81
+ return { ...item, params, };
82
+ }
83
+ /** `YYYY-MM-DD` day of a date or ISO date-time (the day DSS filters runs by); anything else is a usage error. */
84
+ function scenarioDay(value, name) {
85
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T ].*)?$/.exec(value);
86
+ if (match) {
87
+ const day = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
88
+ if (day.getUTCMonth() === Number(match[2]) - 1 && day.getUTCDate() === Number(match[3])) {
89
+ return `${match[1]}-${match[2]}-${match[3]}`;
90
+ }
91
+ }
92
+ throw new ClientValidationError(`${name} must be a YYYY-MM-DD date or ISO date-time, got ${JSON.stringify(value)}.`, "invalid_flag_value");
93
+ }
94
+ /** Who a scenario really runs as: its `runAsUser`, else the last editor (dataikuapi `effective_run_as`). */
95
+ export function effectiveRunAs(scenario) {
96
+ if (typeof scenario.runAsUser === "string")
97
+ return scenario.runAsUser;
98
+ for (const tag of [scenario.versionTag, scenario.creationTag,]) {
99
+ if (isRecord(tag) && isRecord(tag.lastModifiedBy) && typeof tag.lastModifiedBy.login === "string") {
100
+ return tag.lastModifiedBy.login;
101
+ }
102
+ }
103
+ return undefined;
56
104
  }
57
105
  /** A finite number, or undefined for anything else (absent/malformed JSON fields). */
58
106
  function optionalNumber(value) {
@@ -320,12 +368,37 @@ export function scenarioUpdatePreview(current, data) {
320
368
  }
321
369
  function scenarioUpdateVerificationError(mismatches, normalization) {
322
370
  const mismatchPaths = mismatches.map((mismatch) => mismatch.path).join(", ");
323
- return new DataikuError(400, "Scenario Update Verification Failed", JSON.stringify({
371
+ const body = {
324
372
  message: `Scenario update did not persist requested fields after refetch: ${mismatchPaths}`,
325
373
  mismatches,
326
374
  canonicalEditableFields: SCENARIO_CANONICAL_EDITABLE_FIELDS,
327
375
  ...(normalization.length > 0 ? { normalization, } : {}),
328
- }));
376
+ };
377
+ const error = new DataikuError(400, "Scenario Update Verification Failed", JSON.stringify(body));
378
+ // CLI envelopes show `details`, never the body. Only paths go there: reporter and step
379
+ // values can hold webhook URLs, tokens, or credentials.
380
+ error.details = {
381
+ mismatches: mismatches.map((mismatch) => ({
382
+ path: mismatch.path,
383
+ differingPaths: differingLeafPaths(mismatch.expected, mismatch.actual, mismatch.path),
384
+ })),
385
+ canonicalEditableFields: SCENARIO_CANONICAL_EDITABLE_FIELDS,
386
+ };
387
+ return error;
388
+ }
389
+ /** Paths under `path` where the sent value is missing or different in what DSS stored (e.g. `reporters.0.messaging.channelId`). */
390
+ function differingLeafPaths(expected, actual, path) {
391
+ if (Array.isArray(expected)) {
392
+ if (!Array.isArray(actual))
393
+ return [path,];
394
+ return expected.flatMap((item, index) => differingLeafPaths(item, actual[index], `${path}.${index}`));
395
+ }
396
+ if (isRecord(expected)) {
397
+ if (!isRecord(actual))
398
+ return [path,];
399
+ return Object.entries(expected).flatMap(([key, value,]) => differingLeafPaths(value, actual[key], `${path}.${key}`));
400
+ }
401
+ return jsonValueEqual(expected, actual) ? [] : [path,];
329
402
  }
330
403
  export class ScenariosResource extends BaseResource {
331
404
  /** List all scenarios in a project. */
@@ -348,16 +421,24 @@ export class ScenariosResource extends BaseResource {
348
421
  name,
349
422
  projectKey: pk,
350
423
  type: scenarioType,
351
- params: scenarioType === "step_based" ? { steps: [], triggers: [], reporters: [], } : {},
424
+ ...(scenarioType === "step_based"
425
+ ? { params: { steps: [], }, triggers: [], reporters: [], }
426
+ : { params: {}, }),
352
427
  ...opts?.data,
353
428
  };
354
429
  await this.client.post(`/public/api/projects/${this.enc(opts?.projectKey)}/scenarios/`, body);
355
430
  }
356
- /** Trigger a scenario run. */
357
- async run(scenarioId, projectKey) {
431
+ /**
432
+ * Trigger a scenario run. `params` is sent as the POST body (dataikuapi
433
+ * `run(params)`) and reaches the run as its trigger parameters. The result
434
+ * identifies the TRIGGER fire, not a scenario run: resolve it with
435
+ * {@link getRunForTrigger}. `cancelled` is already true when DSS refused the
436
+ * fire because the scenario is running.
437
+ */
438
+ async run(scenarioId, opts) {
358
439
  const scEnc = encodeURIComponent(requireNonEmpty(scenarioId, "scenarioId"));
359
- const result = await this.client.post(`/public/api/projects/${this.enc(projectKey)}/scenarios/${scEnc}/run/`, {});
360
- return { runId: triggerRunIdOf(result, scenarioId), };
440
+ const result = await this.client.post(`/public/api/projects/${this.enc(opts?.projectKey)}/scenarios/${scEnc}/run/`, opts?.params ?? {});
441
+ return triggerFireOf(result, scenarioId);
361
442
  }
362
443
  /** Get the light/status view of a scenario. */
363
444
  async status(scenarioId, projectKey) {
@@ -367,12 +448,15 @@ export class ScenariosResource extends BaseResource {
367
448
  }
368
449
  /** Merge-update a scenario's definition, then refetch and verify requested fields persisted. */
369
450
  async update(scenarioId, data, projectKey) {
370
- const scEnc = encodeURIComponent(scenarioId);
371
- const pkEnc = this.enc(projectKey);
372
- const current = await this.client.get(`/public/api/projects/${pkEnc}/scenarios/${scEnc}/`);
451
+ const current = await this.client.get(`/public/api/projects/${this.enc(projectKey)}/scenarios/${encodeURIComponent(scenarioId)}/`);
452
+ return this.applyUpdate(scenarioId, current, data, projectKey);
453
+ }
454
+ /** PUT the merge of `data` into an already-read scenario, refetch, and verify it persisted. */
455
+ async applyUpdate(scenarioId, current, data, projectKey) {
456
+ const path = `/public/api/projects/${this.enc(projectKey)}/scenarios/${encodeURIComponent(scenarioId)}/`;
373
457
  const preview = scenarioUpdatePreview(current, data);
374
- await this.client.put(`/public/api/projects/${pkEnc}/scenarios/${scEnc}/`, preview.next);
375
- const after = await this.client.get(`/public/api/projects/${pkEnc}/scenarios/${scEnc}/`);
458
+ await this.client.put(path, preview.next);
459
+ const after = await this.client.get(path);
376
460
  const mismatches = scenarioFieldMismatches(after, preview.next, preview.normalizedData);
377
461
  if (mismatches.length > 0) {
378
462
  throw scenarioUpdateVerificationError(mismatches, preview.normalization);
@@ -406,14 +490,15 @@ export class ScenariosResource extends BaseResource {
406
490
  // POST /run/ returns a TRIGGER run id, which differs from the actual scenario
407
491
  // run id; resolve the real run via get-run-for-trigger so a completed scenario
408
492
  // is not misreported as a timeout (the trigger id never matches lastRun).
409
- const trigger = await this.client.post(`${base}/run/`, {});
410
- const tracked = await this.trackTriggerRun(scenarioId, base, trigger, {
493
+ const trigger = await this.client.post(`${base}/run/`, opts?.params ?? {});
494
+ const fire = triggerFireOf(trigger, scenarioId);
495
+ const tracked = await this.trackTriggerRun(scenarioId, opts?.projectKey, trigger, {
411
496
  startedAt,
412
497
  baseIntervalMs,
413
498
  adaptivePolling,
414
499
  timeout,
415
500
  });
416
- const resolvedRunId = tracked.runId || triggerRunIdOf(trigger);
501
+ const resolvedRunId = tracked.runId || fire.triggerRunId;
417
502
  const steps = tracked.timedOut || !tracked.runId
418
503
  ? undefined
419
504
  : await this.runStepSummaries(base, tracked.runId);
@@ -424,9 +509,10 @@ export class ScenariosResource extends BaseResource {
424
509
  success: tracked.outcome === "SUCCESS",
425
510
  elapsedMs: Date.now() - startedAt,
426
511
  pollCount: tracked.pollCount,
427
- ...(triggerRunIdOf(trigger) !== resolvedRunId
428
- ? { triggerRunId: triggerRunIdOf(trigger), }
512
+ ...(fire.triggerRunId !== resolvedRunId || tracked.cancelled
513
+ ? { triggerRunId: fire.triggerRunId, }
429
514
  : {}),
515
+ ...(tracked.cancelled ? { cancelled: true, } : {}),
430
516
  ...(tracked.timedOut ? { timedOut: true, } : {}),
431
517
  ...(steps ? { steps, } : {}),
432
518
  };
@@ -470,7 +556,7 @@ export class ScenariosResource extends BaseResource {
470
556
  extension: "py",
471
557
  });
472
558
  const trigger = await this.client.post(`${base}/run/`, {});
473
- const tracked = await this.trackTriggerRun(scenarioId, base, trigger, {
559
+ const tracked = await this.trackTriggerRun(scenarioId, opts?.projectKey, trigger, {
474
560
  startedAt,
475
561
  baseIntervalMs,
476
562
  adaptivePolling,
@@ -535,16 +621,81 @@ export class ScenariosResource extends BaseResource {
535
621
  const scEnc = encodeURIComponent(scenarioId);
536
622
  await this.client.post(`/public/api/projects/${this.enc(projectKey)}/scenarios/${scEnc}/abort`);
537
623
  }
538
- /** Get the raw last-runs list (GET /scenarios/{id}/get-last-runs/?limit=N). */
624
+ /**
625
+ * Get the raw last-runs list (GET /scenarios/{id}/get-last-runs/?limit=N).
626
+ * `onlyFinished` drops runs still in progress (DSS `onlyFinishedRuns`).
627
+ */
539
628
  async getLastRuns(scenarioId, opts) {
540
629
  if (opts?.limit !== undefined && (!Number.isInteger(opts.limit) || opts.limit < 1)) {
541
630
  throw new ClientValidationError(`limit must be a positive integer, got ${opts.limit}.`);
542
631
  }
543
632
  const scEnc = encodeURIComponent(scenarioId);
544
- const query = opts?.limit !== undefined ? `?limit=${opts.limit}` : "";
545
- const raw = await this.client.get(`/public/api/projects/${this.enc(opts?.projectKey)}/scenarios/${scEnc}/get-last-runs/${query}`);
633
+ const query = new URLSearchParams();
634
+ if (opts?.limit !== undefined)
635
+ query.set("limit", String(opts.limit));
636
+ if (opts?.onlyFinished === true)
637
+ query.set("onlyFinishedRuns", "true");
638
+ const suffix = query.size > 0 ? `?${query}` : "";
639
+ const raw = await this.client.get(`/public/api/projects/${this.enc(opts?.projectKey)}/scenarios/${scEnc}/get-last-runs/${suffix}`);
546
640
  return requireArrayResponse(raw, "scenarios.getLastRuns").map(scenarioRunSummaryFromRaw);
547
641
  }
642
+ /**
643
+ * Runs that started between two days (GET /scenarios/{id}/get-runs-by-date).
644
+ * Dates are `YYYY-MM-DD` or ISO date-times (the time part is dropped, as
645
+ * dataikuapi does). DSS treats `to` as exclusive (verified live: `from=to=today`
646
+ * is empty and today's runs need `to=tomorrow`), so it defaults to tomorrow
647
+ * rather than dataikuapi's today, which would leave today's runs out.
648
+ */
649
+ async getRunsByDate(scenarioId, opts) {
650
+ const query = new URLSearchParams({
651
+ fromDate: scenarioDay(opts.from, "from"),
652
+ toDate: scenarioDay(opts.to ?? new Date(Date.now() + 86_400_000).toLocaleDateString("en-CA"), "to"),
653
+ });
654
+ const raw = await this.client.get(`/public/api/projects/${this.enc(opts.projectKey)}/scenarios/${encodeURIComponent(requireNonEmpty(scenarioId, "scenarioId"))}/get-runs-by-date?${query}`);
655
+ return requireArrayResponse(raw, "scenarios.getRunsByDate").map(scenarioRunSummaryFromRaw);
656
+ }
657
+ /**
658
+ * JUnit XML report of a test scenario run (GET /scenarios/{id}/{runId}/scenario-run-report),
659
+ * or of one step with `stepId` (…/step-run-report?stepId=). DSS only builds
660
+ * reports for scenarios marked as test; the text is byte-bounded like the log.
661
+ */
662
+ async getRunReport(scenarioId, runId, opts) {
663
+ if (opts?.stepId !== undefined)
664
+ requireNonEmpty(opts.stepId, "stepId");
665
+ const maxBytes = Math.max(0, Math.floor(opts?.maxBytes ?? DEFAULT_SCENARIO_MAX_LOG_BYTES));
666
+ const runPath = `/public/api/projects/${this.enc(opts?.projectKey)}/scenarios/${encodeURIComponent(requireNonEmpty(scenarioId, "scenarioId"))}/${encodeURIComponent(requireNonEmpty(runId, "runId"))}`;
667
+ const { text, truncated, } = await this.client.getTextLimited(opts?.stepId === undefined
668
+ ? `${runPath}/scenario-run-report`
669
+ : `${runPath}/step-run-report?stepId=${encodeURIComponent(opts.stepId)}`, maxBytes);
670
+ return { text, truncated, maxBytes, };
671
+ }
672
+ /**
673
+ * Resolve a trigger run (what `run` returns) to its scenario run: the scenario
674
+ * run id once DSS started one, or `CANCELLED` when DSS cancelled the fire (a
675
+ * run already in progress suppresses it) and no run will ever exist.
676
+ */
677
+ async getRunForTrigger(scenarioId, triggerRunId, opts) {
678
+ const pkEnc = this.enc(opts?.projectKey);
679
+ const triggerId = requireNonEmpty(opts?.triggerId ?? "manual", "triggerId");
680
+ const fire = { triggerId, triggerRunId: requireNonEmpty(triggerRunId, "triggerRunId"), };
681
+ const run = await this.client.get(`/public/api/projects/${pkEnc}/scenarios/${encodeURIComponent(requireNonEmpty(scenarioId, "scenarioId"))}/get-run-for-trigger?triggerId=${encodeURIComponent(triggerId)}&triggerRunId=${encodeURIComponent(fire.triggerRunId)}`);
682
+ if (isRecord(run.scenarioRun)) {
683
+ const outcome = isRecord(run.scenarioRun.result) && typeof run.scenarioRun.result.outcome === "string"
684
+ ? run.scenarioRun.result.outcome
685
+ : undefined;
686
+ const start = optionalNumber(run.scenarioRun.start);
687
+ return {
688
+ ...fire,
689
+ cancelled: false,
690
+ state: outcome === undefined ? "RUNNING" : "FINISHED",
691
+ ...(typeof run.scenarioRun.runId === "string" ? { runId: run.scenarioRun.runId, } : {}),
692
+ ...(outcome === undefined ? {} : { outcome, }),
693
+ ...(start === undefined ? {} : { start, }),
694
+ };
695
+ }
696
+ const cancelled = await this.isTriggerFireCancelled(pkEnc, scenarioId, fire);
697
+ return { ...fire, cancelled, state: cancelled ? "CANCELLED" : "PENDING", };
698
+ }
548
699
  /** Get the details of a specific run (GET /scenarios/{id}/{runId}). */
549
700
  async getRunDetails(scenarioId, runId, opts) {
550
701
  return this.client.get(`/public/api/projects/${this.enc(opts?.projectKey)}/scenarios/${encodeURIComponent(scenarioId)}/${encodeURIComponent(runId)}/`);
@@ -604,34 +755,108 @@ export class ScenariosResource extends BaseResource {
604
755
  status: scenarioLightStatusFromRaw(after),
605
756
  };
606
757
  }
758
+ /** The scenario's triggers or reporters, in DSS order (GET /scenarios/{id}/). */
759
+ async listItems(kind, scenarioId, opts) {
760
+ return scenarioListField(await this.getSettings(scenarioId, opts?.projectKey), kind);
761
+ }
762
+ async getSettings(scenarioId, projectKey) {
763
+ return this.client.get(`/public/api/projects/${this.enc(projectKey)}/scenarios/${encodeURIComponent(requireNonEmpty(scenarioId, "scenarioId"))}/`);
764
+ }
607
765
  /**
608
- * Poll get-run-for-trigger until the triggered run reports an outcome or the
609
- * deadline elapses. Shared by runAndWait and runScript: POST /run/ returns a
610
- * TRIGGER run id, which differs from the actual scenario run id, so the real
611
- * run is resolved via get-run-for-trigger (the trigger id never matches
612
- * lastRun and would misreport a completed scenario as a timeout).
766
+ * Append a trigger or reporter. `triggers`/`reporters` are arrays, which a merge
767
+ * update replaces wholesale, so this reads the current list and writes it back
768
+ * with the new element last (then verifies the stored result).
613
769
  */
614
- async trackTriggerRun(scenarioId, base, trigger, timing) {
615
- triggerRunIdOf(trigger, scenarioId);
616
- const trigQuery = triggerQueryOf(trigger);
770
+ async addItem(kind, scenarioId, item, opts) {
771
+ const current = await this.getSettings(scenarioId, opts?.projectKey);
772
+ const items = scenarioListField(current, kind);
773
+ const result = await this.applyUpdate(scenarioId, current, {
774
+ [SCENARIO_LIST_FIELDS[kind]]: [...items.map(withoutServerStamp), item,],
775
+ }, opts?.projectKey);
776
+ const stored = scenarioListField(result.after, kind);
777
+ return { kind, index: items.length, item: stored[items.length], count: stored.length, };
778
+ }
779
+ /** Remove the trigger or reporter selected by `--index`/`--name`, keeping every other element. */
780
+ async removeItem(kind, scenarioId, selector, opts) {
781
+ const current = await this.getSettings(scenarioId, opts?.projectKey);
782
+ const items = scenarioListField(current, kind);
783
+ const index = selectScenarioListIndex(items, selector, kind);
784
+ const result = await this.applyUpdate(scenarioId, current, {
785
+ [SCENARIO_LIST_FIELDS[kind]]: items.flatMap((existing, position) => position === index ? [] : [withoutServerStamp(existing),]),
786
+ }, opts?.projectKey);
787
+ return {
788
+ kind,
789
+ index,
790
+ item: items[index],
791
+ count: scenarioListField(result.after, kind).length,
792
+ };
793
+ }
794
+ /**
795
+ * Set (`user`) or clear (`null`) the scenario's `runAsUser` (admin only for a
796
+ * non-null value). Clearing deletes the key, which a merge update cannot do.
797
+ */
798
+ async setRunAs(scenarioId, user, opts) {
799
+ const path = `/public/api/projects/${this.enc(opts?.projectKey)}/scenarios/${encodeURIComponent(requireNonEmpty(scenarioId, "scenarioId"))}/`;
800
+ const current = await this.client.get(path);
801
+ const { runAsUser: previous, ...withoutRunAs } = current;
802
+ await this.client.put(path, user === null ? withoutRunAs : { ...withoutRunAs, runAsUser: user, });
803
+ const after = await this.client.get(path);
804
+ const stored = typeof after.runAsUser === "string" ? after.runAsUser : null;
805
+ if (stored !== user) {
806
+ const error = new DataikuError(400, "Scenario Update Verification Failed", `Scenario run-as did not persist after refetch: expected ${user === null ? "no runAsUser" : "the requested user"}.`);
807
+ error.details = { mismatches: [{ path: "runAsUser", },], };
808
+ throw error;
809
+ }
810
+ return {
811
+ scenarioId,
812
+ runAsUser: stored,
813
+ effectiveRunAs: effectiveRunAs(after),
814
+ ...(typeof previous === "string" ? { previousRunAsUser: previous, } : {}),
815
+ };
816
+ }
817
+ /**
818
+ * Poll get-run-for-trigger until the triggered run reports an outcome, DSS
819
+ * cancels the trigger fire, or the deadline elapses. Shared by runAndWait and
820
+ * runScript: POST /run/ returns a TRIGGER run id, which differs from the actual
821
+ * scenario run id, so the real run is resolved via get-run-for-trigger (the
822
+ * trigger id never matches lastRun and would misreport a completed scenario as
823
+ * a timeout). While no scenario run exists yet, the trigger fire itself is read:
824
+ * DSS cancels a fire when the scenario is already running (or a newer fire
825
+ * replaces it), and then no run will ever appear.
826
+ */
827
+ async trackTriggerRun(scenarioId, projectKey, trigger, timing) {
828
+ const fire = triggerFireOf(trigger, scenarioId);
829
+ if (fire.cancelled) {
830
+ return { runId: "", outcome: "CANCELLED", timedOut: false, cancelled: true, pollCount: 0, };
831
+ }
832
+ const pkEnc = this.enc(projectKey);
833
+ const base = `/public/api/projects/${pkEnc}/scenarios/${encodeURIComponent(scenarioId)}`;
834
+ const trigQuery = `triggerId=${encodeURIComponent(fire.triggerId)}&triggerRunId=${encodeURIComponent(fire.triggerRunId)}`;
835
+ const remainingMs = () => Math.max(1, timing.timeout - (Date.now() - timing.startedAt));
617
836
  let runId = "";
618
837
  let pollCount = 0;
619
838
  while (true) {
620
839
  if (Date.now() - timing.startedAt >= timing.timeout) {
621
- return { runId, outcome: "TIMEOUT", timedOut: true, pollCount, };
840
+ return { runId, outcome: "TIMEOUT", timedOut: true, cancelled: false, pollCount, };
622
841
  }
623
842
  pollCount += 1;
624
- // Budget the per-poll GET by the remaining wait so a stalling
843
+ // Budget the per-poll GETs by the remaining wait so a stalling
625
844
  // trigger-status endpoint cannot defeat the overall run deadline.
626
- const remainingMs = Math.max(1, timing.timeout - (Date.now() - timing.startedAt));
627
845
  let run;
846
+ let cancelled = false;
628
847
  try {
629
- run = await this.client.get(`${base}/get-run-for-trigger?${trigQuery}`, { timeoutMs: remainingMs, });
848
+ run = await this.client.get(`${base}/get-run-for-trigger?${trigQuery}`, { timeoutMs: remainingMs(), });
849
+ if (!isRecord(run.scenarioRun)) {
850
+ cancelled = await this.isTriggerFireCancelled(pkEnc, scenarioId, fire, remainingMs());
851
+ }
630
852
  }
631
853
  catch (error) {
632
854
  if (!isRequestDeadlineError(error, timing.startedAt + timing.timeout))
633
855
  throw error;
634
- return { runId, outcome: "TIMEOUT", timedOut: true, pollCount, };
856
+ return { runId, outcome: "TIMEOUT", timedOut: true, cancelled: false, pollCount, };
857
+ }
858
+ if (cancelled) {
859
+ return { runId, outcome: "CANCELLED", timedOut: false, cancelled: true, pollCount, };
635
860
  }
636
861
  const scenarioRun = run.scenarioRun;
637
862
  if (scenarioRun) {
@@ -639,7 +864,7 @@ export class ScenariosResource extends BaseResource {
639
864
  const result = scenarioRun.result;
640
865
  const finished = result?.outcome;
641
866
  if (finished) {
642
- return { runId, outcome: finished, timedOut: false, pollCount, };
867
+ return { runId, outcome: finished, timedOut: false, cancelled: false, pollCount, };
643
868
  }
644
869
  }
645
870
  const nextDelayMs = computeNextPollDelayMs({
@@ -648,10 +873,15 @@ export class ScenariosResource extends BaseResource {
648
873
  adaptiveEnabled: timing.adaptivePolling,
649
874
  });
650
875
  const { promise, resolve, } = Promise.withResolvers();
651
- setTimeout(resolve, Math.min(nextDelayMs, Math.max(1, timing.timeout - (Date.now() - timing.startedAt))));
876
+ setTimeout(resolve, Math.min(nextDelayMs, remainingMs()));
652
877
  await promise;
653
878
  }
654
879
  }
880
+ /** Whether DSS cancelled this trigger fire (GET /scenarios/trigger/{id}/{triggerId}?triggerRunId=). */
881
+ async isTriggerFireCancelled(pkEnc, scenarioId, fire, timeoutMs) {
882
+ const raw = await this.client.get(`/public/api/projects/${pkEnc}/scenarios/trigger/${encodeURIComponent(scenarioId)}/${encodeURIComponent(fire.triggerId)}?triggerRunId=${encodeURIComponent(fire.triggerRunId)}`, timeoutMs === undefined ? undefined : { timeoutMs, });
883
+ return raw.cancelled === true;
884
+ }
655
885
  /**
656
886
  * Best-effort step outcome summaries from the run report
657
887
  * (GET /scenarios/{id}/{runId}). Never fails the wait: an unavailable
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Replace every credential in a channel list item, definition, settings object,
3
+ * creation body, or permission payload with `[redacted]`. `secrets` adds exact
4
+ * values to scrub from free text (error messages echoing the request).
5
+ */
6
+ export declare function sanitizeMessagingChannelSecrets<T>(value: T, secrets?: string[]): T;
7
+ /** Exact secret values of a request body, for scrubbing a DSS error that echoes it. */
8
+ export declare function messagingChannelSecretsFromBody(body: unknown): string[];
9
+ /** Scrub an error that may echo a submitted channel configuration before it reaches output. */
10
+ export declare function sanitizeMessagingChannelError(error: unknown, secrets: string[]): unknown;
11
+ /**
12
+ * Optimistic-concurrency token for channel settings. It hashes the redacted
13
+ * view, so it can be published next to redacted output without handing out a
14
+ * checksum of the credentials (a checksum would let low-entropy passwords be
15
+ * guessed offline). Credential-only edits therefore do not change it.
16
+ */
17
+ export declare function messagingChannelSettingsHash(settings: unknown): string;
@@ -0,0 +1,114 @@
1
+ import { isRecord, } from "./records.js";
2
+ import { credentialValues, normalizeSecretKey, sanitizeErrorSecrets, sanitizeSecrets, SECRET_REDACTED, } from "./secret-sanitize.js";
3
+ import { stableHash, } from "./stable-hash.js";
4
+ /**
5
+ * Keys of a messaging-channel configuration that carry credentials or bearer
6
+ * material: SMTP/Graph/SES secrets, Slack/Teams/Google Chat webhooks (the URL
7
+ * itself is the credential), Twilio tokens. Matched after stripping `-`/`_` and
8
+ * lowercasing. The SDK resource layer forwards values verbatim to DSS (over
9
+ * TLS) and returns server responses unmodified; CLI output, plans, previews and
10
+ * errors replace these with `[redacted]`. Login names and sender addresses are
11
+ * identifiers, not secrets, and stay visible.
12
+ */
13
+ const SENSITIVE_MESSAGING_CHANNEL_KEYS = {
14
+ accesskey: true,
15
+ apikey: true,
16
+ apitoken: true,
17
+ authorization: true,
18
+ authorizationtoken: true,
19
+ authtoken: true,
20
+ clientsecret: true,
21
+ credential: true,
22
+ credentials: true,
23
+ password: true,
24
+ privatekey: true,
25
+ secretkey: true,
26
+ token: true,
27
+ webhookkey: true,
28
+ webhooktoken: true,
29
+ webhookurl: true,
30
+ };
31
+ /**
32
+ * SMTP session properties are `{key, value, secret}` entries (`mail.smtp.password`, ...).
33
+ * The value is a secret when DSS flags the entry `secret` or the property name says so.
34
+ */
35
+ const SECRET_PROPERTY_NAME = /pass|secret|token|auth|credential|key/i;
36
+ function isSecretSessionProperty(property) {
37
+ return isRecord(property) && typeof property["key"] === "string"
38
+ && (property["secret"] === true || SECRET_PROPERTY_NAME.test(property["key"]));
39
+ }
40
+ /** Blank the value of secret session properties, at any depth. */
41
+ function redactSessionProperties(value) {
42
+ if (Array.isArray(value))
43
+ return value.map(redactSessionProperties);
44
+ if (!isRecord(value))
45
+ return value;
46
+ return Object.fromEntries(Object.entries(value).map(([name, item,]) => {
47
+ if (normalizeSecretKey(name) === "sessionproperties" && Array.isArray(item)) {
48
+ return [
49
+ name,
50
+ item.map((property) => isSecretSessionProperty(property) ? { ...property, value: SECRET_REDACTED, } : property),
51
+ ];
52
+ }
53
+ return [name, redactSessionProperties(item),];
54
+ }));
55
+ }
56
+ /**
57
+ * Replace every credential in a channel list item, definition, settings object,
58
+ * creation body, or permission payload with `[redacted]`. `secrets` adds exact
59
+ * values to scrub from free text (error messages echoing the request).
60
+ */
61
+ export function sanitizeMessagingChannelSecrets(value, secrets = []) {
62
+ return sanitizeSecrets(redactSessionProperties(value), {
63
+ sensitiveKeys: SENSITIVE_MESSAGING_CHANNEL_KEYS,
64
+ secrets,
65
+ redactUrlUserinfo: true,
66
+ });
67
+ }
68
+ /** Exact secret values of a request body, for scrubbing a DSS error that echoes it. */
69
+ export function messagingChannelSecretsFromBody(body) {
70
+ const found = [];
71
+ const visit = (value) => {
72
+ if (Array.isArray(value)) {
73
+ value.forEach(visit);
74
+ return;
75
+ }
76
+ if (!isRecord(value))
77
+ return;
78
+ for (const [key, item,] of Object.entries(value)) {
79
+ if (SENSITIVE_MESSAGING_CHANNEL_KEYS[normalizeSecretKey(key)] === true) {
80
+ if (typeof item === "string")
81
+ found.push(item);
82
+ continue;
83
+ }
84
+ if (normalizeSecretKey(key) === "sessionproperties" && Array.isArray(item)) {
85
+ for (const property of item) {
86
+ if (isSecretSessionProperty(property) && typeof property["value"] === "string") {
87
+ found.push(property["value"]);
88
+ }
89
+ }
90
+ continue;
91
+ }
92
+ visit(item);
93
+ }
94
+ };
95
+ visit(body);
96
+ return [...found, ...credentialValues(body),];
97
+ }
98
+ /** Scrub an error that may echo a submitted channel configuration before it reaches output. */
99
+ export function sanitizeMessagingChannelError(error, secrets) {
100
+ return sanitizeErrorSecrets(error, {
101
+ sensitiveKeys: SENSITIVE_MESSAGING_CHANNEL_KEYS,
102
+ secrets,
103
+ redactUrlUserinfo: true,
104
+ });
105
+ }
106
+ /**
107
+ * Optimistic-concurrency token for channel settings. It hashes the redacted
108
+ * view, so it can be published next to redacted output without handing out a
109
+ * checksum of the credentials (a checksum would let low-entropy passwords be
110
+ * guessed offline). Credential-only edits therefore do not change it.
111
+ */
112
+ export function messagingChannelSettingsHash(settings) {
113
+ return stableHash(sanitizeMessagingChannelSecrets(settings));
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dataiku-sdk",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "Dataiku DSS SDK and CLI for programmatic access to DSS REST APIs",
5
5
  "repository": {
6
6
  "type": "git",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "dataiku-sdk",
4
- "version": "4.1.0",
4
+ "version": "4.2.0",
5
5
  "description": "Dataiku DSS SDK and agent-oriented CLI for programmatic access to DSS REST APIs.",
6
6
  "homepage": "https://github.com/clssck/dataiku-sdk",
7
7
  "repository": "https://github.com/clssck/dataiku-sdk",
@@ -22,9 +22,9 @@ Reads (`list`, `get`, `preview`, `schema`, `cat`, `log`, `last-runs`): run them
22
22
  name or flag fails before any DSS call, and the error carries the usage line or a "Did you mean".
23
23
  Before the first write of an action, fetch its entry once (syntax, payload schema, side effects,
24
24
  dry-run, cleanup), several in one request: `dss commands run --fields recipe.set-payload,recipe.update`.
25
- Unknown names: `dss commands run` lists resources and actions; `--fields recipe` gives recipe usage
26
- lines. Contract version: `dss agent contract --fields agentContractVersion`. Never load the full
27
- registry into context; export it if needed: `dss commands run --output commands.json`.
25
+ Unknown names: `dss commands run` lists actions; by concept: `dss commands run --contains "build dataset"`.
26
+ Contract version: `dss agent contract --fields agentContractVersion`. Never load the full registry;
27
+ export it if needed: `dss commands run --output commands.json`.
28
28
 
29
29
  ## Safety and results
30
30
 
@@ -58,3 +58,4 @@ Paths are relative to this skill, not CWD. Do not preload all references.
58
58
  | Flow maps and zones | [Flow](references/flow-maps.md) |
59
59
  | Recipes, libraries, notebooks, Git, code envs | [Coding](references/coding.md) |
60
60
  | Failures, build logs, downloads, SQL, schema changes | [Troubleshooting](references/troubleshooting.md) |
61
+ | Scenario alerts, messaging channels, test reports | [Notifications](references/notifications.md) |
@@ -0,0 +1,29 @@
1
+ # Scenario notifications, messaging channels, test reports
2
+
3
+ ## Email notification for a scenario, end to end
4
+
5
+ 1. Find a mail channel: `dss messaging-channel list --family mail` (compact: `id`, `type`, `family`). Empty: there is none; an admin creates one (step 5).
6
+ 2. `dss messaging-channel get CHANNEL_ID` shows its sender. Credentials (password, secret keys, webhook URLs, tokens) are always `[redacted]` in output, plans, and errors.
7
+ 3. Attach it: `dss scenario reporter-add SCENARIO --channel CHANNEL_ID --recipient a@x.com,b@x.com --subject 'Scenario ${scenarioName}: ${outcome}' --message 'Finished with ${outcome}' --dry-run`, then apply without `--dry-run`. `--phase START|END` (default END), `--condition EXPR` limits when it fires. Other channel types (Slack, Teams, ...): pass the reporter JSON with `--data-file`.
8
+ 4. Check: `dss scenario reporter-list SCENARIO`. `dss messaging-channel permissions-get CHANNEL_ID` shows which users and groups may use the channel.
9
+ 5. Admin only, creating a channel: `dss messaging-channel create --type smtp --id mail-main --data-file smtp.json --dry-run` (`--type` smtp, aws-ses-mail, microsoft-graph-mail, slack, msteams, google-chat, twilio, shell; `--data` is its configuration; `--permissions '[{"group":"analysts","canUse":true}]'`, every user when omitted). Record it for cleanup with `--record-cleanup`.
10
+
11
+ Changing a channel: `dss messaging-channel settings-set ID --data '{"sender":"ops@x.com"}' --dry-run` merges into the stored settings; omit credentials you do not change. A `[redacted]` placeholder pasted back is refused. For concurrent edits pass `--expect-hash` with the `settingsHash` from `settings-get` (it covers non-credential fields only).
12
+
13
+ Send mail directly (external side effect, cannot be recalled; always `--dry-run` first, project needs Write content): `dss messaging-channel send CHANNEL_ID --to a@x.com,b@x.com --subject S --body-file body.html --attachment report.csv --project-key PROJ`. HTML unless `--plain-text`; one mail per `--to` address unless `--single-message`; `--attachment` takes comma-separated or repeated paths.
14
+
15
+ ## Test scenarios
16
+
17
+ - `dss project testing-status [--bundle-id ID]`: worst outcome across the last runs of the project's test scenarios (scenarios with `markedAsTest`).
18
+ - `dss project test-report [--bundle-id ID]`: JUnit XML of the last test runs, in `report`; `--output PATH` writes the file instead; `--format html --output PATH` writes the HTML page.
19
+ - Project Deployer: `dss project-deployer testing-status DEPLOYMENT [--bundle-id ID] [--automation-node-id ID]`; `dss project-deployer run-test-scenarios DEPLOYMENT --dry-run` previews running the tests on the Automation node (blocks until they finish: raise `--request-timeout`). `--automation-node-id` is only for multi-node deployments.
20
+ - `dss scenario run-report SCENARIO RUNID [--step-id ID] [--output PATH]`: JUnit XML of one run of a test scenario (non-test scenarios: 400).
21
+
22
+ ## Scenario setup and history
23
+
24
+ - Triggers: `dss scenario trigger-add SCENARIO --frequency minutely|hourly|daily|weekly|monthly [--every N --hour H --minute M --days Monday,Friday --day-of-month D --run-on ON_THE_DAY|LAST_DAY_OF_THE_MONTH|FIRST_WEEK..LAST_WEEK --start-date YYYY-MM-DD --timezone TZ --name N]`, or any trigger type with `--data`. `trigger-list` gives the 0-based `index` for `trigger-remove --index N` (or `--name X`). Each add/remove rewrites the whole list, keeping the others, and verifies it; `--dry-run` previews. Triggers fire only while the scenario is active (`scenario active-set SCENARIO true`).
25
+ - Reporters: `reporter-list`, `reporter-remove --index N|--name X` as above; `--full` shows DSS objects with credentials redacted.
26
+ - Run as: `dss scenario run-as-set SCENARIO --user LOGIN` (administrators) or `--clear` (runs as the last editor); the result's `effectiveRunAs` is the user DSS will use; `scenario status` shows `runAsUser`.
27
+ - Create with settings: `dss scenario create ID NAME --data '{"triggers":[...],"reporters":[...],"markedAsTest":true}'`.
28
+ - Run: `scenario run ID --params '{"region":"EU"}'` (or `--params-file`). Without `--wait` it returns a trigger run id (`triggerRunId`, not a run id); `scenario get-run-for-trigger ID TRIGGERRUNID` returns `state` PENDING, RUNNING, FINISHED (`runId`, `outcome`) or CANCELLED (DSS refused the fire because a run was in progress: wait or `scenario abort`, then run again). `run-and-wait` / `run --wait` end CANCELLED, exit 4, in that case.
29
+ - History: `scenario last-runs ID --only-finished --limit 1` is the last finished run (`outcome`); `scenario runs-by-date ID --from 2026-10-01 --to 2026-11-01` (`--to` exclusive; default tomorrow). `dss future list --scenarios-only` lists running scenario futures.