dataiku-sdk 0.12.3 → 0.12.5

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 (84) hide show
  1. package/README.md +118 -7
  2. package/dist/packages/types/src/index.d.ts +5 -0
  3. package/dist/packages/types/src/index.js +5 -0
  4. package/dist/src/cli/coerce.d.ts +1 -3
  5. package/dist/src/cli/coerce.js +1 -17
  6. package/dist/src/cli/commands/app.js +709 -14
  7. package/dist/src/cli/commands/code-env.js +2 -2
  8. package/dist/src/cli/commands/dashboard.js +1 -1
  9. package/dist/src/cli/commands/dataset.js +2 -2
  10. package/dist/src/cli/commands/folder.js +1 -1
  11. package/dist/src/cli/commands/insight.js +2 -2
  12. package/dist/src/cli/commands/notebook.js +2 -2
  13. package/dist/src/cli/commands/recipe.js +3 -3
  14. package/dist/src/cli/commands/scenario.js +1 -1
  15. package/dist/src/cli/commands/sql.js +1 -1
  16. package/dist/src/cli/commands/variable.js +1 -1
  17. package/dist/src/cli/commands/wiki.js +1 -1
  18. package/dist/src/cli/contract.d.ts +12 -3
  19. package/dist/src/cli/contract.js +805 -49
  20. package/dist/src/cli/flags.js +8 -0
  21. package/dist/src/cli/helpers/app-permissions.d.ts +63 -0
  22. package/dist/src/cli/helpers/app-permissions.js +239 -0
  23. package/dist/src/cli/helpers/app-successor.d.ts +106 -0
  24. package/dist/src/cli/helpers/app-successor.js +625 -0
  25. package/dist/src/cli/helpers/cleanup.js +46 -12
  26. package/dist/src/cli/output.d.ts +12 -1
  27. package/dist/src/cli/output.js +53 -6
  28. package/dist/src/cli/usage.d.ts +1 -1
  29. package/dist/src/cli/usage.js +5 -2
  30. package/dist/src/cli.js +329 -32
  31. package/dist/src/client.d.ts +6 -0
  32. package/dist/src/client.js +28 -1
  33. package/dist/src/errors.d.ts +1 -1
  34. package/dist/src/resources/applications.d.ts +191 -1
  35. package/dist/src/resources/applications.js +549 -10
  36. package/dist/src/resources/datasets.js +8 -0
  37. package/dist/src/resources/futures.js +7 -2
  38. package/dist/src/skill.js +80 -15
  39. package/dist/src/utils/cleanup-ledger.d.ts +34 -1
  40. package/dist/src/utils/cleanup-ledger.js +118 -7
  41. package/dist/src/utils/dss-url.d.ts +6 -0
  42. package/dist/src/utils/dss-url.js +17 -0
  43. package/dist/src/utils/project-incarnation.d.ts +6 -0
  44. package/dist/src/utils/project-incarnation.js +27 -0
  45. package/dist/src/utils/stable-hash.d.ts +11 -0
  46. package/dist/src/utils/stable-hash.js +27 -0
  47. package/node_modules/@sinclair/typebox/build/cjs/compiler/compiler.js +26 -46
  48. package/node_modules/@sinclair/typebox/build/cjs/parser/runtime/guard.d.ts +1 -1
  49. package/node_modules/@sinclair/typebox/build/cjs/parser/runtime/token.d.ts +2 -2
  50. package/node_modules/@sinclair/typebox/build/cjs/syntax/mapping.d.ts +1 -1
  51. package/node_modules/@sinclair/typebox/build/cjs/system/evaluate.d.ts +5 -0
  52. package/node_modules/@sinclair/typebox/build/cjs/system/evaluate.js +11 -0
  53. package/node_modules/@sinclair/typebox/build/cjs/system/index.d.ts +1 -0
  54. package/node_modules/@sinclair/typebox/build/cjs/system/index.js +1 -0
  55. package/node_modules/@sinclair/typebox/build/cjs/type/object/object.d.ts +2 -2
  56. package/node_modules/@sinclair/typebox/build/cjs/type/object/object.js +18 -2
  57. package/node_modules/@sinclair/typebox/build/cjs/value/parse/parse.d.ts +1 -1
  58. package/node_modules/@sinclair/typebox/build/esm/compiler/compiler.mjs +27 -47
  59. package/node_modules/@sinclair/typebox/build/esm/parser/runtime/guard.d.mts +1 -1
  60. package/node_modules/@sinclair/typebox/build/esm/parser/runtime/token.d.mts +2 -2
  61. package/node_modules/@sinclair/typebox/build/esm/syntax/mapping.d.mts +1 -1
  62. package/node_modules/@sinclair/typebox/build/esm/system/evaluate.d.mts +5 -0
  63. package/node_modules/@sinclair/typebox/build/esm/system/evaluate.mjs +7 -0
  64. package/node_modules/@sinclair/typebox/build/esm/system/index.d.mts +1 -0
  65. package/node_modules/@sinclair/typebox/build/esm/system/index.mjs +1 -0
  66. package/node_modules/@sinclair/typebox/build/esm/type/composite/composite.mjs +2 -2
  67. package/node_modules/@sinclair/typebox/build/esm/type/const/const.mjs +3 -3
  68. package/node_modules/@sinclair/typebox/build/esm/type/instantiate/instantiate.mjs +2 -2
  69. package/node_modules/@sinclair/typebox/build/esm/type/mapped/mapped.mjs +3 -3
  70. package/node_modules/@sinclair/typebox/build/esm/type/module/compute.mjs +2 -2
  71. package/node_modules/@sinclair/typebox/build/esm/type/object/object.d.mts +2 -2
  72. package/node_modules/@sinclair/typebox/build/esm/type/object/object.mjs +18 -2
  73. package/node_modules/@sinclair/typebox/build/esm/type/omit/omit.mjs +3 -3
  74. package/node_modules/@sinclair/typebox/build/esm/type/partial/partial.mjs +3 -3
  75. package/node_modules/@sinclair/typebox/build/esm/type/pick/pick.mjs +3 -3
  76. package/node_modules/@sinclair/typebox/build/esm/type/record/record.mjs +3 -3
  77. package/node_modules/@sinclair/typebox/build/esm/type/required/required.mjs +3 -3
  78. package/node_modules/@sinclair/typebox/build/esm/type/type/json.mjs +2 -2
  79. package/node_modules/@sinclair/typebox/build/esm/value/delta/delta.mjs +4 -4
  80. package/node_modules/@sinclair/typebox/build/esm/value/parse/parse.d.mts +1 -1
  81. package/node_modules/@sinclair/typebox/package.json +1 -1
  82. package/package.json +7 -5
  83. package/packages/types/dist/index.d.ts +5 -0
  84. package/packages/types/dist/index.js +5 -0
@@ -3,14 +3,15 @@ import * as path from "node:path";
3
3
  import { validateCredentials, } from "../auth.js";
4
4
  import { getCredentialsPath, saveCredentials, } from "../config.js";
5
5
  import { DataikuError, } from "../errors.js";
6
- import { jobBuildTargetTypeFromFlags, json, jsonInput, num, parseBooleanOption, parseJsonObject, requiredJsonInput, schemaColumnsInput, textInput, } from "./coerce.js";
6
+ import { APP_MANIFEST_CONCURRENCY_CONTROL, } from "../resources/applications.js";
7
+ import { jobBuildTargetTypeFromFlags, json, jsonInput, num, parseBooleanOption, parseJsonObject, requiredJsonInput, rewritePairsFromFlags, schemaColumnsInput, stringField, textInput, } from "./coerce.js";
7
8
  import { commands, } from "./commands/index.js";
8
9
  import { dataikuEnvironmentEnabled, } from "./env.js";
9
- import { BOOLEAN_FLAGS, FLAG_ALIASES, KNOWN_LONG_FLAGS, } from "./flags.js";
10
+ import { BOOLEAN_FLAGS, FLAG_ALIASES, KNOWN_LONG_FLAGS, SHORT_FLAGS, } from "./flags.js";
10
11
  import { flowZoneColor, flowZoneMoveItems, flowZoneName, } from "./helpers/flow-zone.js";
11
12
  import { recipeBackupPath, recipeRunShouldWait, } from "./helpers/recipe.js";
12
13
  import { encodedProjectEndpointForPlan, planResult, } from "./output.js";
13
- import { resolveCredentials, resolveTlsSettings, } from "./runtime.js";
14
+ import { resolveTlsSettings, } from "./runtime.js";
14
15
  import { requireArgs, UsageError, } from "./usage.js";
15
16
  import { AGENT_CONTRACT_SCHEMA_ID, AGENT_CONTRACT_VERSION, cliVersionResult, JSON_SCHEMA_DRAFT, } from "./version.js";
16
17
  function codeEnvWait(flags) {
@@ -109,6 +110,7 @@ const READ_ACTIONS = new Set([
109
110
  "list-sql",
110
111
  "log",
111
112
  "log-url",
113
+ "manifest-version",
112
114
  "map",
113
115
  "metadata",
114
116
  "peek",
@@ -127,6 +129,7 @@ const READ_ACTIONS = new Set([
127
129
  "settings-get",
128
130
  "status-by-partition",
129
131
  "usages",
132
+ "verify-instance",
130
133
  ]);
131
134
  const PROJECT_SCOPED_RESOURCES = new Set([
132
135
  "analysis",
@@ -157,12 +160,20 @@ const AUTHENTICATED_AGENT_FLAGS = [
157
160
  "insecure",
158
161
  "ca-cert",
159
162
  ];
160
- export const COMMANDS_USAGE = "dss commands run [--json]";
161
- const COMMANDS_DESCRIPTION = "Print the machine-readable command registry for agent planning.";
162
- const COMMANDS_EXAMPLES = ["dss commands run", "dss commands run --json",];
163
- export const AGENT_CONTRACT_USAGE = "dss agent contract";
164
- const AGENT_CONTRACT_DESCRIPTION = "Print the versioned JSON contract agents should use to drive dss.";
165
- const AGENT_CONTRACT_EXAMPLES = ["dss agent contract",];
163
+ export const COMMANDS_USAGE = "dss commands run [--fields PATHS] [--json]";
164
+ const COMMANDS_DESCRIPTION = "Print the machine-readable command registry for agent planning; scope it with --fields RESOURCE, RESOURCE.ACTION, or RESOURCE.ACTION.FIELD instead of reading the whole registry.";
165
+ const COMMANDS_EXAMPLES = [
166
+ "dss commands run --fields dataset --json",
167
+ "dss commands run --fields dataset.create --json",
168
+ "dss commands run --fields dataset.create.usage,dataset.create.description,dataset.create.flags,dataset.create.examples --json",
169
+ ];
170
+ export const AGENT_CONTRACT_COMMAND = "dss agent contract";
171
+ export const AGENT_CONTRACT_USAGE = "dss agent contract [--fields PATHS] [--json]";
172
+ const AGENT_CONTRACT_DESCRIPTION = "Print the versioned JSON agent contract; scope bootstrap fields, use commands.actions to enumerate the surface, and read schemas only when needed.";
173
+ const AGENT_CONTRACT_EXAMPLES = [
174
+ "dss agent contract --fields protocol,agentContractVersion,cli,stdio,planning,compatibility --json",
175
+ "dss agent contract --fields commands.actions --json",
176
+ ];
166
177
  const VERSION_USAGE = "dss version";
167
178
  const VERSION_DESCRIPTION = "Print the CLI version and git revision as JSON.";
168
179
  const VERSION_EXAMPLES = ["dss version", "dss --version",];
@@ -186,7 +197,6 @@ const FIXTURES_EXAMPLES = [
186
197
  ];
187
198
  const ALLOWED_CLEANUP_ACTIONS = new Set([
188
199
  // Must mirror every cleanup.argv shape emitted by cleanupLedgerEntry().
189
- "project delete",
190
200
  "analysis delete",
191
201
  "model-evaluation-store delete",
192
202
  "ml-task delete",
@@ -201,6 +211,7 @@ const ALLOWED_CLEANUP_ACTIONS = new Set([
201
211
  "code-env delete",
202
212
  "folder delete",
203
213
  "folder delete-file",
214
+ "app delete-instance",
204
215
  ]);
205
216
  export function isAllowedCleanupAction(resource, action) {
206
217
  return ALLOWED_CLEANUP_ACTIONS.has(`${resource} ${action}`);
@@ -258,7 +269,71 @@ function exampleArgv(example) {
258
269
  const tokens = splitShellLike(example);
259
270
  if (!tokens || tokens[0] !== "dss")
260
271
  return undefined;
261
- return tokens.slice(1);
272
+ const argv = canonicalizeArgv(tokens.slice(1));
273
+ // Global flag aliases like `dss --version` have no command path to emit.
274
+ return argv[0]?.startsWith("--") ? undefined : argv;
275
+ }
276
+ /**
277
+ * Fold a parsed shell argv into the machine-canonical flag form the argv
278
+ * schemas advertise: value flags become one `--flag=VALUE` token and boolean
279
+ * flags stay standalone, so structured examples always validate against their
280
+ * own generated argv schema. Alias flags are rewritten to their canonical
281
+ * name. Positional tokens and tokens after the `--` separator pass through
282
+ * unchanged; the human-facing `shell` examples are never rewritten.
283
+ */
284
+ function canonicalizeArgv(argv) {
285
+ const canonical = [];
286
+ let afterSeparator = false;
287
+ for (let index = 0; index < argv.length; index++) {
288
+ const token = argv[index];
289
+ if (afterSeparator || token === "--") {
290
+ canonical.push(token);
291
+ afterSeparator = true;
292
+ continue;
293
+ }
294
+ if (token.length === 2 && token[0] === "-" && token[1] !== "-") {
295
+ const flagName = SHORT_FLAGS[token[1]];
296
+ if (!flagName) {
297
+ canonical.push(token);
298
+ continue;
299
+ }
300
+ if (BOOLEAN_FLAGS.has(flagName)) {
301
+ canonical.push(`--${flagName}`);
302
+ continue;
303
+ }
304
+ const next = argv[index + 1];
305
+ if (next === undefined || (next !== "-" && next.startsWith("-"))) {
306
+ canonical.push(token);
307
+ continue;
308
+ }
309
+ canonical.push(`--${flagName}=${next}`);
310
+ index++;
311
+ continue;
312
+ }
313
+ if (!token.startsWith("--")) {
314
+ canonical.push(token);
315
+ continue;
316
+ }
317
+ const eqIdx = token.indexOf("=");
318
+ const rawFlagName = eqIdx === -1 ? token.slice(2) : token.slice(2, eqIdx);
319
+ const flagName = FLAG_ALIASES[rawFlagName] ?? rawFlagName;
320
+ if (eqIdx !== -1) {
321
+ canonical.push(BOOLEAN_FLAGS.has(flagName) ? `--${flagName}` : `--${flagName}=${token.slice(eqIdx + 1)}`);
322
+ continue;
323
+ }
324
+ if (BOOLEAN_FLAGS.has(flagName)) {
325
+ canonical.push(`--${flagName}`);
326
+ continue;
327
+ }
328
+ const next = argv[index + 1];
329
+ if (next === undefined || next.startsWith("--")) {
330
+ canonical.push(`--${flagName}`);
331
+ continue;
332
+ }
333
+ canonical.push(`--${flagName}=${next}`);
334
+ index++;
335
+ }
336
+ return canonical;
262
337
  }
263
338
  function structuredExamples(examples, examplePayload) {
264
339
  return (examples ?? []).map((shell) => {
@@ -286,13 +361,121 @@ function payloadJsonSchema(payloadSchema) {
286
361
  ? { type: "array", items: true, }
287
362
  : { type: "object", additionalProperties: true, };
288
363
  }
289
- function argvJsonSchema(resource, action, _flags, _positionals, _requiredFlags, _requiredOneOf) {
364
+ /**
365
+ * Value flags the runtime meaningfully accepts with an empty value. Only flags
366
+ * with proven clear semantics belong here: `--version-notes ""` clears saved
367
+ * notes, while `--content ""` clears text payloads such as wiki articles,
368
+ * insights, and project-library files. Canonical argv patterns emit `*` for
369
+ * these and `+` for every other value flag, so `--flag=` alone can never
370
+ * validate for a required non-empty flag.
371
+ */
372
+ const ALLOW_EMPTY_VALUE_FLAGS = {
373
+ content: true,
374
+ "version-notes": true,
375
+ };
376
+ function flagValueTokenPattern(flag) {
377
+ const names = [flag.name, ...(flag.aliases ?? []),];
378
+ const quantifier = flag.allowEmptyValue === true ? "*" : "+";
379
+ return `^--(${names.join("|")})=[\\s\\S]${quantifier}$`;
380
+ }
381
+ function booleanFlagPattern(names) {
382
+ return `^--(${names.join("|")})$`;
383
+ }
384
+ function shortFlagsFor(names) {
385
+ return Object.entries(SHORT_FLAGS)
386
+ .filter(([, canonical,]) => names.includes(canonical))
387
+ .map(([short,]) => short);
388
+ }
389
+ function shortFlagPattern(flags) {
390
+ const shortChars = shortFlagsFor(flags.filter((flag) => flag.kind === "boolean").map((flag) => flag.name));
391
+ return shortChars.length > 0 ? `^-(${shortChars.join("|")})$` : undefined;
392
+ }
393
+ /**
394
+ * Machine-canonical argv prefix for a registry entry: the public usage tokens
395
+ * between `dss` and the first positional. Synthetic single-action commands
396
+ * whose usage omits their action token (e.g. `dss batch ...`, `dss version`)
397
+ * use an actionless prefix, while real command paths that spell `run` in their
398
+ * usage (e.g. `dss commands run [--json]`) keep the action token.
399
+ */
400
+ function argvPrefix(resource, action, usage) {
401
+ const usageTokens = usage.split(/\s+/).filter((token) => token.length > 0);
402
+ const head = usageTokens.slice(1);
403
+ return head[0] === resource && head[1] === action ? [resource, action,] : [resource,];
404
+ }
405
+ /**
406
+ * Describe one invocation as a canonical argv token array. Agents emit the
407
+ * usage-derived prefix, then the required `<...>` positionals first; flags and
408
+ * values follow, so `prefixItems` pins the head of the array and the tail is
409
+ * validated against the canonical flag token alphabet. Constraints mirror the
410
+ * runtime checks in `validateRegistryCommandInputs`: required positional arity,
411
+ * required flags, required choice groups, and rejection of unknown long and
412
+ * short flags. Value flags only validate in `--flag=VALUE` form, so a bare
413
+ * value flag can never pass without its value; boolean flags stay standalone.
414
+ * Tradeoff: a positional/value token that itself begins with `-` (e.g. a
415
+ * negative number) is rejected unless it is the literal `-` stdin marker or a
416
+ * supported flag; the runtime additionally accepts such tokens via its
417
+ * negative-number and `--` separator rules.
418
+ */
419
+ function argvJsonSchema(resource, action, flags, usage, requiredFlags, requiredOneOf) {
420
+ const prefix = argvPrefix(resource, action, usage);
421
+ const aliases = {};
422
+ for (const flag of flags) {
423
+ aliases[flag.name] = flag.aliases ?? [];
424
+ }
425
+ const booleanNames = flags.filter((flag) => flag.kind === "boolean")
426
+ .flatMap((flag) => [flag.name, ...(flag.aliases ?? []),]);
427
+ const valueFlags = flags.filter((flag) => flag.kind === "value");
428
+ const requiredPositionals = requiredPlanPositionals(usage);
429
+ const itemSchemas = [
430
+ { not: { pattern: "^-", }, },
431
+ { const: "-", },
432
+ { pattern: "^--$", },
433
+ ];
434
+ if (booleanNames.length > 0)
435
+ itemSchemas.push({ pattern: booleanFlagPattern(booleanNames), });
436
+ if (valueFlags.length > 0) {
437
+ itemSchemas.push({ pattern: valueFlags.map(flagValueTokenPattern).join("|"), });
438
+ }
439
+ const shortPattern = shortFlagPattern(flags);
440
+ if (shortPattern)
441
+ itemSchemas.push({ pattern: shortPattern, });
442
+ const contains = (names) => {
443
+ const requiredValuePatterns = flags
444
+ .filter((flag) => flag.kind === "value" && names.includes(flag.name))
445
+ .map(flagValueTokenPattern);
446
+ const pattern = requiredValuePatterns.length > 0
447
+ ? requiredValuePatterns.join("|")
448
+ : booleanFlagPattern(names);
449
+ return { contains: { pattern, }, minContains: 1, };
450
+ };
451
+ const constraints = requiredFlags.map((name) => contains([name, ...(aliases[name] ?? []),]));
452
+ for (const choice of requiredOneOf) {
453
+ const alternativeSchema = (alternative) => {
454
+ const required = alternative.map((name) => contains([name, ...(aliases[name] ?? []),]));
455
+ return required.length === 1 ? required[0] : { allOf: required, };
456
+ };
457
+ constraints.push({ anyOf: choice.oneOf.map(alternativeSchema), });
458
+ }
290
459
  return {
460
+ $schema: JSON_SCHEMA_DRAFT,
291
461
  type: "object",
292
462
  additionalProperties: true,
293
463
  required: ["argv",],
294
464
  properties: {
295
- argv: { type: "array", items: { type: "string", }, minItems: 1, },
465
+ argv: {
466
+ type: "array",
467
+ prefixItems: [
468
+ ...prefix.map((token) => ({ const: token, })),
469
+ ...requiredPositionals.map((name) => ({
470
+ type: "string",
471
+ title: name.replace(/\.\.\.$/, ""),
472
+ not: { pattern: "^-", },
473
+ })),
474
+ ],
475
+ items: { type: "string", anyOf: itemSchemas, },
476
+ minItems: prefix.length + requiredPositionals.length,
477
+ ...(constraints.length > 0 ? { allOf: constraints, } : {}),
478
+ },
296
479
  resource: { const: resource, },
297
480
  action: { const: action, },
298
481
  },
@@ -311,17 +494,25 @@ function unsafeOutputs(resource, action, flags, producesLocalFile) {
311
494
  });
312
495
  }
313
496
  if (producesLocalFile) {
497
+ const sensitivePermissions = resource === "app" && action === "permissions-snapshot";
314
498
  outputs.push({
315
499
  condition: "--output or --output-file",
316
500
  kind: "local-file",
317
- detail: "Command writes bytes to a local path and returns JSON metadata on stdout.",
501
+ detail: sensitivePermissions
502
+ ? "Command writes access-control identities and permissions to an owner-only local file."
503
+ : "Command writes bytes to a local path and returns JSON metadata on stdout.",
504
+ ...(sensitivePermissions
505
+ ? {
506
+ safeAlternative: "Keep the file owner-only and outside version control unless repository policy explicitly permits committing access-control data.",
507
+ }
508
+ : {}),
318
509
  });
319
510
  }
320
511
  return outputs.length > 0 ? outputs : undefined;
321
512
  }
322
- export function buildCommandSchemas(resource, action, flags, positionals, requiredFlags, requiredOneOf, payloadSchema, outputShape) {
513
+ export function buildCommandSchemas(resource, action, flags, requiredFlags, requiredOneOf, payloadSchema, outputShape, usage) {
323
514
  return {
324
- argv: argvJsonSchema(resource, action, flags, positionals, requiredFlags, requiredOneOf),
515
+ argv: argvJsonSchema(resource, action, flags, usage, requiredFlags, requiredOneOf),
325
516
  ...(payloadSchema ? { input: payloadJsonSchema(payloadSchema), } : {}),
326
517
  output: outputJsonSchema(outputShape),
327
518
  };
@@ -364,6 +555,10 @@ const EXPLICIT_REGISTRY_OVERRIDES = {
364
555
  "scenario.update": {
365
556
  examplePayload: { active: false, },
366
557
  },
558
+ "variable.set": {
559
+ requiredFlags: [],
560
+ requiredOneOf: [{ oneOf: [["standard",], ["local",],], },],
561
+ },
367
562
  "wiki.update": {
368
563
  examplePayload: { article: { name: "Updated article", }, },
369
564
  },
@@ -388,6 +583,11 @@ function inferSideEffect(resource, action) {
388
583
  }
389
584
  if (resource === "install-skill")
390
585
  return "write";
586
+ // `project export` streams an archive over the API and writes it to a local
587
+ // path; the DSS-side project is untouched, so it reads remotely and only
588
+ // mutates the local filesystem.
589
+ if (resource === "project" && action === "export")
590
+ return "read";
391
591
  if (resource === "data-quality" && action === "compute")
392
592
  return "write";
393
593
  if (READ_ACTIONS.has(action))
@@ -395,7 +595,7 @@ function inferSideEffect(resource, action) {
395
595
  if (/^(create|clone|restore|update|delete|set|save|upload|run|build|abort|move|refresh|clear|unload|install|login|logout|add|remove|publish|activate|deploy|import|export|preload|upgrade|start|stop|restart|duplicate|put|rename|reply|compute|organize)/
396
596
  .test(action)
397
597
  // Compound actions whose mutating verb is a suffix (e.g. permissions-set, dataset-compute).
398
- || /-(set|compute)$/.test(action)) {
598
+ || /-(set|compute|restore)$/.test(action)) {
399
599
  return "write";
400
600
  }
401
601
  return "read";
@@ -499,12 +699,14 @@ function splitTopLevelChoices(group) {
499
699
  const parts = [];
500
700
  let depth = 0;
501
701
  let current = "";
502
- for (const char of group) {
702
+ for (let index = 0; index < group.length; index++) {
703
+ const char = group[index];
503
704
  if (char === "[" || char === "(")
504
705
  depth++;
505
706
  else if (char === "]" || char === ")")
506
707
  depth--;
507
- if (char === "|" && depth === 0) {
708
+ const nextAlternative = group.slice(index + 1).trimStart();
709
+ if (char === "|" && depth === 0 && nextAlternative.startsWith("--")) {
508
710
  parts.push(current);
509
711
  current = "";
510
712
  }
@@ -519,9 +721,9 @@ function flagsInUsageFragment(fragment) {
519
721
  return extractUsageFlags(fragment.replace(/\[[^\]]*\]/g, " "));
520
722
  }
521
723
  /**
522
- * Split required usage flags into unconditional flags and mutually-exclusive
523
- * choice groups. A required `(--a X | --b Y)` group becomes a requiredOneOf entry
524
- * (pick exactly one alternative; an alternative listing several flags must be
724
+ * Split required usage flags into unconditional flags and required choice
725
+ * groups. A required `(--a X | --b Y)` group becomes a requiredOneOf entry
726
+ * (at least one alternative; an alternative listing several flags must be
525
727
  * supplied together) instead of marking every flag as unconditionally required.
526
728
  */
527
729
  function deriveRequiredUsage(usage) {
@@ -536,6 +738,14 @@ function deriveRequiredUsage(usage) {
536
738
  const oneOf = alternatives
537
739
  .map((alternative) => flagsInUsageFragment(alternative))
538
740
  .filter((alternativeFlags) => alternativeFlags.length > 0);
741
+ // A group mixing a positional alternative with flags (e.g. sql query's
742
+ // `(SQL | --sql QUERY | --sql-file PATH | --sql - | --stdin)`) cannot be
743
+ // expressed as a flag-only choice: filtering out the flagless alternative
744
+ // would falsely require one of the flags. Omit the group from the flag
745
+ // contract entirely; the handler stays the runtime authority.
746
+ if (alternatives.some((alternative) => flagsInUsageFragment(alternative).length === 0)) {
747
+ continue;
748
+ }
539
749
  if (oneOf.length > 1)
540
750
  requiredOneOf.push({ oneOf, });
541
751
  else if (oneOf.length === 1)
@@ -556,7 +766,7 @@ const GLOBAL_FLAG_VALUE_HINTS = {
556
766
  /** Derive a value placeholder (and enum members) for each value flag from its usage token. */
557
767
  function extractFlagValueHints(usage) {
558
768
  const hints = new Map();
559
- for (const match of usage.matchAll(/--([a-z0-9-]+)\s+([a-z]+(?:\|[a-z]+)+)/g)) {
769
+ for (const match of usage.matchAll(/--([a-z0-9-]+)\s+([A-Za-z][A-Za-z0-9_-]*(?:\|[A-Za-z][A-Za-z0-9_-]*)+)(?![A-Za-z0-9_=-])/g)) {
560
770
  const flag = FLAG_ALIASES[match[1]] ?? match[1];
561
771
  if (!hints.has(flag)) {
562
772
  hints.set(flag, { valueType: "enum", enumValues: match[2].split("|"), });
@@ -575,20 +785,40 @@ function inferPayloadSchema(inputContract) {
575
785
  }
576
786
  return { ...inputContract, jsonShape: "object", };
577
787
  }
578
- function inferExitCodes(asyncKind) {
788
+ /**
789
+ * Long-running commands surface exit 4 when the remote work itself fails.
790
+ * `batch run` is synchronous, yet any step it dispatches may be a long-running
791
+ * lifecycle command (app instance create/delete, job build, scenario run), so a
792
+ * batch can end on 4 as well and must advertise it.
793
+ * `recipe assert-unchanged` is synchronous: a drift check exits 4 with the
794
+ * stable code `assertion_failed`, advertised as `assertionFailure` and never
795
+ * as a long-running outcome.
796
+ */
797
+ function inferExitCodes(resource, action, asyncKind) {
798
+ const longRunning = asyncKind !== "none" || `${resource}.${action}` === "batch.run";
799
+ const assertion = `${resource}.${action}` === "recipe.assert-unchanged"
800
+ || `${resource}.${action}` === "batch.run";
579
801
  return {
580
802
  ok: 0,
581
803
  usage: 1,
582
804
  error: 2,
583
805
  transient: 3,
584
- ...(asyncKind !== "none" ? { longRunningFailure: 4, } : {}),
806
+ ...(longRunning ? { longRunningFailure: 4, } : {}),
807
+ ...(assertion ? { assertionFailure: 4, } : {}),
585
808
  };
586
809
  }
587
810
  function cleanupCommandFromDeleteUsage(resource, action) {
811
+ if (resource === "project" && (action === "create" || action === "duplicate")) {
812
+ return undefined;
813
+ }
588
814
  if (!(action.startsWith("create") || action === "clone" || action === "duplicate")) {
589
815
  return undefined;
590
816
  }
591
- const deleteAction = action === "create-rule" ? "delete-rule" : "delete";
817
+ const deleteAction = action === "create-rule"
818
+ ? "delete-rule"
819
+ : action === "create-instance" || action === "create-successor-instance"
820
+ ? "delete-instance"
821
+ : "delete";
592
822
  const deleteUsage = commands[resource]?.[deleteAction]?.usage;
593
823
  if (!deleteUsage)
594
824
  return undefined;
@@ -623,23 +853,54 @@ function inferAsyncKind(resource, action) {
623
853
  return "future";
624
854
  if (resource === "code" && action === "run")
625
855
  return "future";
856
+ if (resource === "app"
857
+ && ["create-instance", "create-successor-instance", "delete-instance",].includes(action)) {
858
+ return "future";
859
+ }
626
860
  return "none";
627
861
  }
628
- function inferIdempotency(sideEffect, action, usage) {
862
+ function inferIdempotency(resource, sideEffect, action, usage) {
629
863
  if (sideEffect === "read")
630
864
  return "safe";
631
865
  if (action.startsWith("create") && usage.includes("--if-not-exists"))
632
866
  return "if-not-exists";
633
867
  if (action.startsWith("delete") && usage.includes("--if-exists"))
634
868
  return "if-exists";
869
+ if (`${resource}.${action}` === "app.set-manifest-version")
870
+ return "none";
871
+ // `app delete-instance` converges without an `--if-exists` flag: an absent
872
+ // target project is reported as an already-absent success instead of an
873
+ // error, so replaying the delete cannot fail on absence alone.
874
+ if (`${resource}.${action}` === "app.delete-instance")
875
+ return "convergent";
635
876
  if (/^(clear|refresh|set|save)/.test(action))
636
877
  return "convergent";
637
878
  return "none";
638
879
  }
880
+ /**
881
+ * `app create-instance` and `app create-successor-instance` hand back a DSS
882
+ * creation future, and the created project only becomes safe to delete once
883
+ * that future is terminal. A directly runnable delete command cannot express
884
+ * that gate, so these commands advertise no `cleanupCommand` at all: recovery
885
+ * goes through the recorded cleanup ledger, whose replay carries the creation
886
+ * future ID and refuses to delete while creation is unconfirmed.
887
+ */
888
+ const LEDGER_ONLY_CLEANUP_KEYS = {
889
+ "app.create-instance": true,
890
+ "app.create-successor-instance": true,
891
+ };
892
+ const LEDGER_ONLY_CLEANUP_HINT = "Pass `--record-cleanup PATH` when creating, then recover with `dss cleanup --file PATH --apply`. Do not issue direct instance deletion as cleanup: it bypasses the creation-future gate and can delete the project while DSS is still creating it.";
639
893
  export function inferCleanupHint(resource, action) {
640
894
  if (!(action.startsWith("create") || action === "clone"))
641
895
  return undefined;
642
- const deleteAction = action === "create-rule" ? "delete-rule" : "delete";
896
+ if (LEDGER_ONLY_CLEANUP_KEYS[`${resource}.${action}`] === true) {
897
+ return LEDGER_ONLY_CLEANUP_HINT;
898
+ }
899
+ const deleteAction = action === "create-rule"
900
+ ? "delete-rule"
901
+ : action === "create-instance" || action === "create-successor-instance"
902
+ ? "delete-instance"
903
+ : "delete";
643
904
  const deleteUsage = commands[resource]?.[deleteAction]?.usage;
644
905
  const ifExists = deleteUsage?.includes("--if-exists") ? " --if-exists" : "";
645
906
  if (resource === "code-env") {
@@ -687,9 +948,12 @@ function buildRegistryEntry(resource, action, meta) {
687
948
  ?? inferPayloadSchema(inputContract);
688
949
  const examplePayload = meta.examplePayload
689
950
  ?? EXPLICIT_REGISTRY_OVERRIDES[registryKey(resource, action)]?.examplePayload;
690
- const cleanupCommand = meta.cleanupCommand
951
+ const inferredCleanupCommand = meta.cleanupCommand
691
952
  ?? EXPLICIT_REGISTRY_OVERRIDES[registryKey(resource, action)]?.cleanupCommand
692
953
  ?? cleanupCommandFromDeleteUsage(resource, action);
954
+ const cleanupCommand = LEDGER_ONLY_CLEANUP_KEYS[`${resource}.${action}`] === true
955
+ ? undefined
956
+ : inferredCleanupCommand;
693
957
  const flagMetadata = flags.map((name) => {
694
958
  const aliases = Object.entries(FLAG_ALIASES)
695
959
  .filter(([raw, canonical,]) => canonical === name && new RegExp(`--${raw}(?![a-z0-9-])`).test(meta.usage))
@@ -698,15 +962,17 @@ function buildRegistryEntry(resource, action, meta) {
698
962
  const kind = flagKind(name);
699
963
  if (kind === "boolean")
700
964
  return { name, kind, ...aliasPart, };
965
+ const allowEmptyPart = ALLOW_EMPTY_VALUE_FLAGS[name] === true ? { allowEmptyValue: true, } : {};
701
966
  const hint = valueHints.get(name) ?? GLOBAL_FLAG_VALUE_HINTS[name];
702
967
  if (!hint)
703
- return { name, kind, ...aliasPart, };
968
+ return { name, kind, ...aliasPart, ...allowEmptyPart, };
704
969
  return {
705
970
  name,
706
971
  kind,
707
972
  valueType: hint.valueType,
708
973
  ...(hint.enumValues ? { enumValues: hint.enumValues, } : {}),
709
974
  ...aliasPart,
975
+ ...allowEmptyPart,
710
976
  };
711
977
  });
712
978
  const positionals = extractPositionals(meta.usage);
@@ -734,17 +1000,17 @@ function buildRegistryEntry(resource, action, meta) {
734
1000
  producesLocalFile,
735
1001
  mutatesDss,
736
1002
  async: asyncKind,
737
- idempotency: inferIdempotency(sideEffect, action, meta.usage),
1003
+ idempotency: inferIdempotency(resource, sideEffect, action, meta.usage),
738
1004
  dryRun: meta.usage.includes("--dry-run"),
739
1005
  requiredFlags: uniqueRequiredFlags,
740
1006
  optionalFlags: uniqueOptionalFlags,
741
1007
  ...(requiredOneOf.length > 0 ? { requiredOneOf, } : {}),
742
1008
  ...(payloadSchema ? { payloadSchema, } : {}),
743
- schemas: buildCommandSchemas(resource, action, flagMetadata, positionals, uniqueRequiredFlags, requiredOneOf, payloadSchema, outputShape),
1009
+ schemas: buildCommandSchemas(resource, action, flagMetadata, uniqueRequiredFlags, requiredOneOf, payloadSchema, outputShape, meta.usage),
744
1010
  ...(unsafe ? { unsafeOutputs: unsafe, } : {}),
745
1011
  ...(examplePayload !== undefined ? { examplePayload, } : {}),
746
1012
  ...(cleanupCommand ? { cleanupCommand, } : {}),
747
- exitCodes: inferExitCodes(asyncKind),
1013
+ exitCodes: inferExitCodes(resource, action, asyncKind),
748
1014
  ...(cleanupHint ? { cleanupHint, } : {}),
749
1015
  agentContractVersion: AGENT_CONTRACT_VERSION,
750
1016
  };
@@ -838,6 +1104,7 @@ function commandFlagJsonSchema() {
838
1104
  valueType: { type: "string", },
839
1105
  enumValues: { type: "array", items: { type: "string", }, },
840
1106
  aliases: { type: "array", items: { type: "string", }, },
1107
+ allowEmptyValue: { type: "boolean", },
841
1108
  },
842
1109
  };
843
1110
  }
@@ -978,6 +1245,15 @@ export function buildAgentContract() {
978
1245
  cli: cliVersionResult(),
979
1246
  commands: {
980
1247
  discoveryCommand: "dss commands run",
1248
+ scopedDiscoveryCommand: "dss commands run --fields RESOURCE[.ACTION[.FIELD...]] --json",
1249
+ actionIndexCommand: "dss agent contract --fields commands.actions --json",
1250
+ compactOutputFlag: "--json",
1251
+ compactOutputHint: "Pass --json to emit compact single-line JSON and reduce agent context usage.",
1252
+ scopedDiscoveryExamples: [
1253
+ "dss commands run --fields dataset --json",
1254
+ "dss commands run --fields dataset.create --json",
1255
+ ],
1256
+ scopedDiscoveryHint: "--fields RESOURCE returns every action of one resource; --fields RESOURCE.ACTION returns a single registry entry keyed by the dotted path; append .FIELD paths to project nested metadata. Comma-separate paths to select several.",
981
1257
  actions: commandActionSummary(registry),
982
1258
  },
983
1259
  schemas: {
@@ -1000,11 +1276,18 @@ export function buildAgentContract() {
1000
1276
  format: "jsonl",
1001
1277
  events: ["warning", "trace", "error",],
1002
1278
  error: "single-final-error-event-on-nonzero-exit",
1279
+ failureRouting: {
1280
+ commandFailure: "doctor/batch/cleanup failures: single JSON stdout; empty stderr",
1281
+ dispatchFailure: "usage/unknown/transport/internal failures: empty stdout; single JSON error stderr",
1282
+ },
1003
1283
  },
1004
1284
  },
1005
1285
  planning: {
1006
1286
  discoveryCommand: "dss commands run",
1007
- contractCommand: AGENT_CONTRACT_USAGE,
1287
+ contractCommand: AGENT_CONTRACT_COMMAND,
1288
+ bootstrapCommand: "dss agent contract --fields protocol,agentContractVersion,cli,stdio,planning,compatibility --json",
1289
+ preferredDiscoveryCommand: "dss commands run --fields RESOURCE.ACTION --json",
1290
+ actionIndexCommand: "dss agent contract --fields commands.actions --json",
1008
1291
  mutatingCommandsAdvertisePlan: true,
1009
1292
  },
1010
1293
  compatibility: {
@@ -1021,20 +1304,54 @@ function exitCodesOnFailure(entry) {
1021
1304
  ...(entry.exitCodes.longRunningFailure !== undefined
1022
1305
  ? { longRunningFailure: entry.exitCodes.longRunningFailure, }
1023
1306
  : {}),
1307
+ ...(entry.exitCodes.assertionFailure !== undefined
1308
+ ? { assertionFailure: entry.exitCodes.assertionFailure, }
1309
+ : {}),
1024
1310
  };
1025
1311
  }
1312
+ /**
1313
+ * Plan-local project key resolution. `--plan` is a purely local preview, so it
1314
+ * resolves the target project from the explicit `--project-key` flag and the
1315
+ * documented `DATAIKU_PROJECT_KEY` environment variable only. The saved
1316
+ * credentials file is never opened and no API key is ever resolved, so planning
1317
+ * can neither depend on nor leak stored secrets.
1318
+ */
1319
+ function planProjectKeyFromArgs(flags) {
1320
+ const fromFlag = flags["project-key"];
1321
+ if (typeof fromFlag === "string" && fromFlag.trim().length > 0)
1322
+ return fromFlag.trim();
1323
+ if (!dataikuEnvironmentEnabled())
1324
+ return undefined;
1325
+ const fromEnv = process.env.DATAIKU_PROJECT_KEY;
1326
+ return fromEnv !== undefined && fromEnv.trim().length > 0 ? fromEnv.trim() : undefined;
1327
+ }
1328
+ /**
1329
+ * Commands whose plan must pin the target with an explicit `--project-key`
1330
+ * instead of falling back to ambient `DATAIKU_PROJECT_KEY`, so a cleanup plan
1331
+ * can never silently retarget a project the caller did not name.
1332
+ */
1333
+ const EXPLICIT_PLAN_PROJECT_KEY = { "app.delete-instance": true, };
1334
+ /** Plan-local project key, or the canonical `--project-key` usage error. */
1335
+ function requiredPlanProjectKey(flags, usage) {
1336
+ return planProjectKeyFromArgs(flags) ?? requiredPlanFlag(flags, "project-key", usage);
1337
+ }
1026
1338
  function projectKeyForPlan(entry, flags) {
1027
1339
  if (!entry.requiresProject)
1028
1340
  return undefined;
1029
- const projectKey = resolveCredentials(flags).projectKey;
1341
+ if (EXPLICIT_PLAN_PROJECT_KEY[`${entry.resource}.${entry.action}`] === true) {
1342
+ return requiredPlanFlag(flags, "project-key", entry.usage);
1343
+ }
1344
+ const projectKey = planProjectKeyFromArgs(flags);
1030
1345
  if (projectKey)
1031
1346
  return projectKey;
1032
1347
  throw new UsageError(`Missing project key. Pass --project-key or set DATAIKU_PROJECT_KEY before planning ${entry.resource} ${entry.action}.`);
1033
1348
  }
1034
1349
  function requiredPlanFlag(flags, name, usage) {
1035
1350
  const value = flags[name];
1351
+ // Return trimmed like the runtime's requiredStringFlag so plans advertise
1352
+ // exactly the identifier the command will act on.
1036
1353
  if (typeof value === "string" && value.trim().length > 0)
1037
- return value;
1354
+ return value.trim();
1038
1355
  throw new UsageError(`--${name} is required. Usage: ${usage}`);
1039
1356
  }
1040
1357
  function optionalJsonFlag(flags, name) {
@@ -1289,6 +1606,31 @@ export function commandPlanShape(resource, action, args, flags, entry, projectKe
1289
1606
  payload: { datasetName: name, connection, dsType, projectKey, },
1290
1607
  };
1291
1608
  }
1609
+ case "dataset.clone": {
1610
+ const source = args[0];
1611
+ const target = args[1];
1612
+ return {
1613
+ method: "POST",
1614
+ endpoint: projectEndpoint("/datasets/"),
1615
+ identifiers: { source, target, },
1616
+ payload: {
1617
+ sourceDataset: source,
1618
+ targetDataset: target,
1619
+ path: flags["path"],
1620
+ table: flags["table"],
1621
+ metastoreTableName: flags["metastore-table"],
1622
+ allowSamePath: flags["allow-same-path"] === true,
1623
+ projectKey,
1624
+ },
1625
+ };
1626
+ }
1627
+ case "dataset.rename":
1628
+ return {
1629
+ method: "POST",
1630
+ endpoint: projectEndpoint("/actions/renameDataset"),
1631
+ identifiers: { oldName: args[0], newName: args[1], },
1632
+ payload: { oldName: args[0], newName: args[1], },
1633
+ };
1292
1634
  case "dataset.delete":
1293
1635
  return {
1294
1636
  method: "DELETE",
@@ -1311,6 +1653,55 @@ export function commandPlanShape(resource, action, args, flags, entry, projectKe
1311
1653
  identifiers: { name: id, },
1312
1654
  payload: requiredPlanJsonInput(flags, entry.usage),
1313
1655
  };
1656
+ case "recipe.add-input":
1657
+ case "recipe.remove-input": {
1658
+ const role = flags["role"] ?? "main";
1659
+ return {
1660
+ method: "PUT",
1661
+ endpoint: projectEndpoint(`/recipes/${encodeURIComponent(args[0])}`),
1662
+ identifiers: { recipe: args[0], dataset: args[1], role, },
1663
+ payload: {
1664
+ operation: action === "add-input" ? "append" : "remove",
1665
+ dataset: args[1],
1666
+ role,
1667
+ projectKey,
1668
+ },
1669
+ };
1670
+ }
1671
+ case "recipe.clone": {
1672
+ const positionalSource = args[0];
1673
+ const fromFlag = typeof flags["from"] === "string" ? flags["from"].trim() : "";
1674
+ const source = positionalSource ?? fromFlag;
1675
+ if (!source) {
1676
+ throw new UsageError(`Source recipe is required. Usage: ${entry.usage}`, "missing_required_flag");
1677
+ }
1678
+ if (positionalSource && fromFlag && positionalSource !== fromFlag) {
1679
+ throw new UsageError("Positional source and --from must match when both are provided.", "invalid_enum");
1680
+ }
1681
+ const toFlag = typeof flags["to"] === "string" ? flags["to"].trim() : "";
1682
+ const nameFlag = typeof flags["name"] === "string" ? flags["name"].trim() : "";
1683
+ const target = toFlag || nameFlag;
1684
+ if (!target) {
1685
+ throw new UsageError(`--name or --to is required. Usage: ${entry.usage}`, "missing_required_flag");
1686
+ }
1687
+ return {
1688
+ method: "POST",
1689
+ endpoint: projectEndpoint("/recipes/"),
1690
+ identifiers: { source, target, },
1691
+ payload: {
1692
+ sourceRecipe: source,
1693
+ targetRecipe: target,
1694
+ inputRewrites: rewritePairsFromFlags(flags, "replace-input"),
1695
+ outputRewrites: rewritePairsFromFlags(flags, "replace-output"),
1696
+ payloadTextRewrites: rewritePairsFromFlags(flags, "replace-payload-text"),
1697
+ outputDataset: flags["output"],
1698
+ copyOutputSettings: flags["copy-output-settings"] === true,
1699
+ outputPath: flags["path"],
1700
+ metastoreTableName: flags["metastore-table"],
1701
+ projectKey,
1702
+ },
1703
+ };
1704
+ }
1314
1705
  case "recipe.delete":
1315
1706
  return {
1316
1707
  method: "DELETE",
@@ -1698,13 +2089,376 @@ export function commandPlanShape(resource, action, args, flags, entry, projectKe
1698
2089
  identifiers: { filePath: id, },
1699
2090
  payload: uploadPayload(id),
1700
2091
  };
1701
- case "app.create-instance":
2092
+ case "app.save-instance-manifest": {
2093
+ const targetProjectKey = requiredPlanProjectKey(flags, entry.usage);
2094
+ return {
2095
+ method: "PUT",
2096
+ endpoint: `/public/api/projects/${encodeURIComponent(targetProjectKey)}/app-manifest`,
2097
+ identifiers: { projectKey: targetProjectKey, },
2098
+ payload: requiredPlanJsonInput(flags, entry.usage),
2099
+ };
2100
+ }
2101
+ case "app.create-instance": {
2102
+ const payload = requiredPlanJsonInput(flags, entry.usage);
2103
+ const rawTargetProjectKey = stringField(payload, ["targetProjectKey",]);
2104
+ if (!rawTargetProjectKey || rawTargetProjectKey.trim() === "") {
2105
+ throw new UsageError("Instance creation payload must include a non-empty targetProjectKey.", "validation_failed", `Usage: ${entry.usage}`);
2106
+ }
2107
+ // The runtime trims and rewrites body.targetProjectKey; the plan
2108
+ // advertises the normalized identifier and payload.
2109
+ const targetProjectKey = rawTargetProjectKey.trim();
2110
+ const normalizedPayload = { ...payload, targetProjectKey, };
1702
2111
  return {
1703
2112
  method: "POST",
1704
2113
  endpoint: `/public/api/apps/${encodeURIComponent(id)}/instances`,
1705
- identifiers: { appId: id, },
1706
- payload: requiredPlanJsonInput(flags, entry.usage),
2114
+ identifiers: {
2115
+ appId: id,
2116
+ targetProjectKey,
2117
+ incarnationControl: "client-side-non-atomic-future-target-and-creation-tag-join",
2118
+ incarnationObservationRequests: [
2119
+ {
2120
+ method: "GET",
2121
+ endpoint: `/public/api/projects/${encodeURIComponent(targetProjectKey)}/`,
2122
+ when: flags["wait"] === true
2123
+ ? "after-terminal-future-target"
2124
+ : "conditional-inline-hasResult-target",
2125
+ intent: "After DSS reports inline or terminal creation success for the requested key, observe creationTag for later cleanup binding.",
2126
+ },
2127
+ ],
2128
+ preflightRequests: [
2129
+ {
2130
+ method: "GET",
2131
+ endpoint: `/public/api/projects/${encodeURIComponent(targetProjectKey)}/`,
2132
+ when: "before-create",
2133
+ intent: "Require the target project to be absent before creating the instance.",
2134
+ },
2135
+ {
2136
+ method: "GET",
2137
+ endpoint: "/public/api/projects/",
2138
+ when: "conditional",
2139
+ intent: "Fallback list probe issued only when the direct project GET is forbidden (403) to confirm the target is absent; an unconfirmed target rejects creation.",
2140
+ },
2141
+ ],
2142
+ note: "Strict preflight: the instance POST runs only after the payload targetProjectKey is confirmed absent. Creation never writes into an existing or unprovable project.",
2143
+ },
2144
+ payload: normalizedPayload,
2145
+ wait: flags["wait"] === true,
2146
+ };
2147
+ }
2148
+ case "app.set-manifest-version": {
2149
+ const targetProjectKey = requiredPlanProjectKey(flags, entry.usage);
2150
+ const payloadPatch = {};
2151
+ const version = flags["manifest-version"];
2152
+ if (version !== undefined) {
2153
+ if (version.trim() === "") {
2154
+ throw new UsageError("App manifest version must be a non-empty string.", "validation_failed", `Usage: ${entry.usage}`);
2155
+ }
2156
+ payloadPatch.version = version;
2157
+ }
2158
+ const versionNotes = flags["version-notes"];
2159
+ if (versionNotes !== undefined)
2160
+ payloadPatch.versionNotes = versionNotes;
2161
+ if (version === undefined && versionNotes === undefined) {
2162
+ throw new UsageError("At least one of --manifest-version or --version-notes is required.", "usage_error", `Usage: ${entry.usage}`);
2163
+ }
2164
+ const expectHash = flags["expect-hash"];
2165
+ if (expectHash !== undefined && !/^[a-fA-F0-9]{64}$/.test(expectHash)) {
2166
+ throw new UsageError("Expected manifest hash must be a 64-character SHA-256 hex digest.", "validation_failed", `Usage: ${entry.usage}`);
2167
+ }
2168
+ return {
2169
+ method: "PUT",
2170
+ endpoint: `/public/api/projects/${encodeURIComponent(targetProjectKey)}/app-manifest`,
2171
+ identifiers: {
2172
+ projectKey: targetProjectKey,
2173
+ payloadPatch,
2174
+ ...(expectHash !== undefined ? { expectHash: expectHash.toLowerCase(), } : {}),
2175
+ concurrencyControl: APP_MANIFEST_CONCURRENCY_CONTROL,
2176
+ staleReadCheck: expectHash === undefined
2177
+ ? "none"
2178
+ : "client-side-expect-hash-compare-before-put",
2179
+ note: expectHash === undefined
2180
+ ? "Unconditional PUT: no stale-read check is armed because --expect-hash was not supplied."
2181
+ : "The hash is compared client-side against a fresh read; the PUT itself stays unconditional, so this command can overwrite a writer that commits between that read and this PUT without detecting the lost update.",
2182
+ },
1707
2183
  };
2184
+ }
2185
+ case "app.create-successor-instance": {
2186
+ const sourceProjectKey = requiredPlanFlag(flags, "from", entry.usage);
2187
+ const targetProjectKey = requiredPlanFlag(flags, "to", entry.usage);
2188
+ if (sourceProjectKey === targetProjectKey) {
2189
+ throw new UsageError("--from and --to must be different project keys.", "validation_failed", `Usage: ${entry.usage}`);
2190
+ }
2191
+ const targetProjectName = flags["name"];
2192
+ const copyPermissions = parseBooleanOption(flags["copy-permissions"], "--copy-permissions")
2193
+ ?? false;
2194
+ return {
2195
+ method: "POST",
2196
+ endpoint: `/public/api/apps/${encodeURIComponent(id)}/instances`,
2197
+ identifiers: {
2198
+ appId: id,
2199
+ sourceProjectKey,
2200
+ targetProjectKey,
2201
+ ...(targetProjectName !== undefined ? { targetProjectName, } : {}),
2202
+ copyPermissions,
2203
+ incarnationControl: "client-side-non-atomic-future-target-and-creation-tag-join",
2204
+ postFutureRequests: [
2205
+ {
2206
+ method: "GET",
2207
+ endpoint: `/public/api/projects/${encodeURIComponent(targetProjectKey)}/`,
2208
+ intent: "After the terminal future names the successor key, observe creationTag and bind later target checks and cleanup to that hash.",
2209
+ },
2210
+ ],
2211
+ ...(copyPermissions
2212
+ ? {
2213
+ permissionConcurrencyControl: "client-side-non-atomic-stale-identity-and-hash-checks",
2214
+ }
2215
+ : {}),
2216
+ preflightRequests: [
2217
+ {
2218
+ method: "GET",
2219
+ endpoint: `/public/api/apps/${encodeURIComponent(id)}/instances/`,
2220
+ when: "before-create",
2221
+ intent: "Verify the --from project is a registered instance of the app.",
2222
+ },
2223
+ {
2224
+ method: "GET",
2225
+ endpoint: `/public/api/projects/${encodeURIComponent(sourceProjectKey)}/app-manifest`,
2226
+ when: "before-create",
2227
+ intent: "Verify the --from project is an APP_INSTANCE project.",
2228
+ },
2229
+ {
2230
+ method: "GET",
2231
+ endpoint: `/public/api/projects/${encodeURIComponent(targetProjectKey)}/`,
2232
+ when: "before-create",
2233
+ intent: "Require the --to target project to be absent before creating the successor.",
2234
+ },
2235
+ {
2236
+ method: "GET",
2237
+ endpoint: "/public/api/projects/",
2238
+ when: "conditional",
2239
+ intent: "Fallback list probe issued only when the direct project GET is forbidden (403) to confirm the target is absent; an unconfirmed target rejects creation.",
2240
+ },
2241
+ ],
2242
+ ...(copyPermissions
2243
+ ? {
2244
+ permissionRequests: [
2245
+ {
2246
+ method: "GET",
2247
+ endpoint: `/public/api/projects/${encodeURIComponent(sourceProjectKey)}/permissions`,
2248
+ intent: "Snapshot the predecessor instance ACL before creation.",
2249
+ },
2250
+ {
2251
+ method: "GET",
2252
+ endpoint: `/public/api/projects/${encodeURIComponent(targetProjectKey)}/permissions`,
2253
+ intent: "Read the successor ACL to decide whether the copy is a no-op.",
2254
+ },
2255
+ {
2256
+ method: "GET",
2257
+ endpoint: `/public/api/projects/${encodeURIComponent(targetProjectKey)}/`,
2258
+ intent: "Recheck successor creationTag after reading its ACL; stop if the project key was reused.",
2259
+ },
2260
+ {
2261
+ method: "GET",
2262
+ endpoint: `/public/api/projects/${encodeURIComponent(sourceProjectKey)}/permissions`,
2263
+ intent: "Recheck the predecessor ACL immediately before the write and stop if its hash drifted.",
2264
+ },
2265
+ {
2266
+ method: "GET",
2267
+ endpoint: `/public/api/projects/${encodeURIComponent(targetProjectKey)}/`,
2268
+ intent: "Recheck successor creationTag immediately before the unconditional permission PUT.",
2269
+ },
2270
+ {
2271
+ method: "PUT",
2272
+ endpoint: `/public/api/projects/${encodeURIComponent(targetProjectKey)}/permissions`,
2273
+ intent: "Apply the predecessor ACL snapshot to the successor instance.",
2274
+ },
2275
+ {
2276
+ method: "GET",
2277
+ endpoint: `/public/api/projects/${encodeURIComponent(targetProjectKey)}/permissions`,
2278
+ intent: "Verify the successor ACL hash equals the predecessor snapshot hash.",
2279
+ },
2280
+ {
2281
+ method: "GET",
2282
+ endpoint: `/public/api/projects/${encodeURIComponent(targetProjectKey)}/`,
2283
+ intent: "Detect successor project-key reuse across the permission write and verification read.",
2284
+ },
2285
+ ],
2286
+ }
2287
+ : {}),
2288
+ note: "Additive, non-transactional: strict preflight verifies the predecessor instance and the --to target absence before the single instance POST, then waits on the DSS future. The predecessor is never modified or deleted; cleanup targets only the successor key. DSS exposes no immutable future target ID, conditional DELETE, or conditional permission PUT: later creationTag and ACL checks narrow and detect races but cannot atomically join creation provenance or serialize writes.",
2289
+ },
2290
+ payload: {
2291
+ targetProjectKey,
2292
+ targetProjectName: targetProjectName ?? targetProjectKey,
2293
+ },
2294
+ wait: true,
2295
+ };
2296
+ }
2297
+ case "app.delete-instance": {
2298
+ const targetProjectKey = requiredPlanFlag(flags, "project-key", entry.usage);
2299
+ const futureId = flags["future-id"] === undefined
2300
+ ? undefined
2301
+ : requiredPlanFlag(flags, "future-id", entry.usage);
2302
+ const expectedProjectIncarnation = flags["expect-project-incarnation"] === undefined
2303
+ ? undefined
2304
+ : requiredPlanFlag(flags, "expect-project-incarnation", entry.usage);
2305
+ if (expectedProjectIncarnation !== undefined
2306
+ && !/^[0-9a-f]{64}$/.test(expectedProjectIncarnation)) {
2307
+ throw new UsageError("--expect-project-incarnation must be a 64-character lowercase SHA-256 hash.", "validation_failed");
2308
+ }
2309
+ const unconfirmedCreation = parseBooleanOption(flags["unconfirmed-creation"], "--unconfirmed-creation") ?? false;
2310
+ const manifestProbe = `/public/api/projects/${encodeURIComponent(targetProjectKey)}/app-manifest`;
2311
+ const projectDetailsProbe = `/public/api/projects/${encodeURIComponent(targetProjectKey)}/`;
2312
+ const typeValidationRequests = [
2313
+ {
2314
+ method: "GET",
2315
+ endpoint: manifestProbe,
2316
+ when: "before-delete",
2317
+ intent: "Verify the target is an APP_INSTANCE project before deleting.",
2318
+ },
2319
+ {
2320
+ method: "GET",
2321
+ endpoint: projectDetailsProbe,
2322
+ when: expectedProjectIncarnation === undefined
2323
+ ? "conditional-type-check-before-delete"
2324
+ : "incarnation-and-conditional-type-check-before-delete",
2325
+ intent: expectedProjectIncarnation === undefined
2326
+ ? "Fallback probe issued only when the app-manifest response omits projectAppType (live DSS does)."
2327
+ : "Recompute the current project-incarnation hash from creationTag after the manifest probe; the same response supplies projectAppType when the manifest omits it.",
2328
+ },
2329
+ ];
2330
+ const preflightRequests = typeValidationRequests;
2331
+ if (unconfirmedCreation) {
2332
+ return {
2333
+ identifiers: {
2334
+ projectKey: targetProjectKey,
2335
+ ...(futureId !== undefined ? { futureId, } : {}),
2336
+ unconfirmedCreation: true,
2337
+ note: "Indeterminate creation without a DSS future ID: no DSS request is issued. The command reports an unresolved cleanup failure (exit 4, cleanupResolved false) without deleting, because creation may still be running.",
2338
+ },
2339
+ };
2340
+ }
2341
+ if (futureId === undefined) {
2342
+ return {
2343
+ method: "DELETE",
2344
+ endpoint: `/public/api/projects/${encodeURIComponent(targetProjectKey)}`,
2345
+ identifiers: {
2346
+ projectKey: targetProjectKey,
2347
+ ...(expectedProjectIncarnation === undefined
2348
+ ? {}
2349
+ : {
2350
+ projectIncarnationGate: {
2351
+ required: false,
2352
+ provided: true,
2353
+ expectedHash: expectedProjectIncarnation,
2354
+ },
2355
+ incarnationControl: "client-side-non-atomic-stale-identity-check",
2356
+ }),
2357
+ preflightRequests,
2358
+ note: expectedProjectIncarnation === undefined
2359
+ ? "Convergent direct delete: the manifest preflight rejects non-instance targets, an absent target (404) is an already-absent success issued without any DELETE, and only a verified instance target receives the project DELETE. No project-incarnation binding was requested."
2360
+ : "Incarnation-bound direct delete: after verifying APP_INSTANCE type, a project GET must match the expected creationTag hash before an unconditional DELETE. DSS exposes no immutable project ID or conditional DELETE, so this client-side check narrows but cannot serialize against project-key reuse after the GET.",
2361
+ },
2362
+ };
2363
+ }
2364
+ return {
2365
+ method: "DELETE",
2366
+ endpoint: `/public/api/projects/${encodeURIComponent(targetProjectKey)}`,
2367
+ identifiers: {
2368
+ projectKey: targetProjectKey,
2369
+ futureId,
2370
+ projectIncarnationGate: {
2371
+ required: true,
2372
+ provided: expectedProjectIncarnation !== undefined,
2373
+ ...(expectedProjectIncarnation === undefined
2374
+ ? {}
2375
+ : { expectedHash: expectedProjectIncarnation, }),
2376
+ },
2377
+ incarnationControl: "client-side-non-atomic-future-target-and-creation-tag-join",
2378
+ preflightRequests: [
2379
+ {
2380
+ method: "GET",
2381
+ endpoint: manifestProbe,
2382
+ when: "before-future-wait",
2383
+ intent: "Verify the target is an APP_INSTANCE project before the supplied future is touched, so an invalid target cannot affect it.",
2384
+ },
2385
+ {
2386
+ method: "GET",
2387
+ endpoint: projectDetailsProbe,
2388
+ when: "conditional-before-wait",
2389
+ intent: "Fallback probe issued only when the pre-wait app-manifest response omits projectAppType (live DSS does).",
2390
+ },
2391
+ ],
2392
+ futureGate: [
2393
+ {
2394
+ method: "GET",
2395
+ endpoint: `/public/api/futures/${encodeURIComponent(futureId)}?peek=false`,
2396
+ intent: "Wait for the supplied creation future to settle or time out; never abort it, and repeat this read-only GET while it is live.",
2397
+ },
2398
+ ],
2399
+ postFutureValidationRequests: expectedProjectIncarnation === undefined
2400
+ ? []
2401
+ : typeValidationRequests,
2402
+ note: expectedProjectIncarnation === undefined
2403
+ ? "The target type is verified before the future is touched. Waiting never aborts the future, but a terminal target match still cannot authorize deletion without --expect-project-incarnation; the command then exits with validation_failed and issues no DELETE."
2404
+ : "The target type is verified before the future is touched. The DELETE runs only after the terminal future reports the requested target, then a later project GET still matches the recorded creationTag hash and the target is re-verified as APP_INSTANCE. DSS exposes no immutable future target ID or conditional DELETE: the future target and creationTag are independent, non-atomic observations that narrow but cannot eliminate a project-key-reuse race.",
2405
+ },
2406
+ };
2407
+ }
2408
+ case "app.permissions-restore": {
2409
+ const file = requiredPlanFlag(flags, "file", entry.usage);
2410
+ const targetProjectKey = requiredPlanProjectKey(flags, entry.usage);
2411
+ const permissionsEndpoint = `/public/api/projects/${encodeURIComponent(targetProjectKey)}/permissions`;
2412
+ const projectDetailsEndpoint = `/public/api/projects/${encodeURIComponent(targetProjectKey)}/`;
2413
+ return {
2414
+ method: "PUT",
2415
+ endpoint: permissionsEndpoint,
2416
+ identifiers: {
2417
+ file,
2418
+ projectKey: targetProjectKey,
2419
+ localPreflight: [
2420
+ "Read and hash-verify the owner-only snapshot file.",
2421
+ "Require its project key and canonical DSS URL to match this invocation.",
2422
+ ],
2423
+ preflightRequests: [
2424
+ {
2425
+ method: "GET",
2426
+ endpoint: projectDetailsEndpoint,
2427
+ intent: "Require the snapshot project-incarnation hash to match creationTag.",
2428
+ },
2429
+ {
2430
+ method: "GET",
2431
+ endpoint: permissionsEndpoint,
2432
+ intent: "Read current permissions; an equal hash makes the PUT unnecessary.",
2433
+ },
2434
+ {
2435
+ method: "GET",
2436
+ endpoint: projectDetailsEndpoint,
2437
+ intent: "Recheck the project incarnation after the permission read and immediately before any PUT.",
2438
+ },
2439
+ ],
2440
+ conditionalWrite: {
2441
+ method: "PUT",
2442
+ endpoint: permissionsEndpoint,
2443
+ when: "permissions-differ-and-dry-run-is-false",
2444
+ },
2445
+ verificationRequests: [
2446
+ {
2447
+ method: "GET",
2448
+ endpoint: permissionsEndpoint,
2449
+ intent: "Verify DSS persisted the desired permission hash.",
2450
+ },
2451
+ {
2452
+ method: "GET",
2453
+ endpoint: projectDetailsEndpoint,
2454
+ intent: "Detect project-key reuse across the permission write.",
2455
+ },
2456
+ ],
2457
+ incarnationControl: "client-side-non-atomic-stale-identity-check",
2458
+ note: "DSS exposes no conditional permission PUT or immutable project ID. The repeated creationTag checks narrow and detect key-reuse races but cannot serialize the check with the PUT.",
2459
+ },
2460
+ };
2461
+ }
1708
2462
  case "statistics.create-worksheet":
1709
2463
  return {
1710
2464
  method: "POST",
@@ -1940,16 +2694,6 @@ export function commandPlanShape(resource, action, args, flags, entry, projectKe
1940
2694
  },
1941
2695
  };
1942
2696
  }
1943
- case "project.export": {
1944
- const output = requiredPlanFlag(flags, "output", entry.usage);
1945
- return {
1946
- method: "POST",
1947
- endpoint: `/public/api/projects/${encodeURIComponent(id)}/export`,
1948
- identifiers: { projectKey: id, output, },
1949
- payload: jsonInput(flags) ?? {},
1950
- localWrites: [{ path: output, source: "remote project archive", after: "POST", },],
1951
- };
1952
- }
1953
2697
  case "project.import":
1954
2698
  return {
1955
2699
  method: "POST",
@@ -2027,6 +2771,18 @@ export function buildMutationPlan(resource, action, meta, args, flags) {
2027
2771
  plannedAndDryRun: flags["dry-run"] === true,
2028
2772
  });
2029
2773
  }
2774
+ /**
2775
+ * Batch plans may surface exit 4: any dispatched step can be a long-running
2776
+ * lifecycle command whose remote work fails, or a synchronous assertion
2777
+ * mismatch (`recipe assert-unchanged`). Batch itself stays async "none".
2778
+ */
2779
+ export const BATCH_PLAN_EXIT_CODES = {
2780
+ usage: 1,
2781
+ error: 2,
2782
+ transient: 3,
2783
+ longRunningFailure: 4,
2784
+ assertionFailure: 4,
2785
+ };
2030
2786
  export const BATCH_USAGE = "dss batch (--data JSON|--data-file PATH|--stdin) [--continue-on-error] [--dry-run]";
2031
2787
  const BATCH_DESCRIPTION = "Run a sequence of dss commands from a JSON array of argv arrays. Fail-fast by default; returns one envelope with a per-step ok/result/error and exits non-zero if any step failed.";
2032
2788
  export const BATCH_HINT = 'Pass a JSON array of argv arrays, e.g. [["dataset","list"],["recipe","update","r","--data-file","p.json"]].';