dataiku-sdk 0.12.4 → 0.12.6

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 (86) hide show
  1. package/README.md +119 -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 +17 -2
  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 +9 -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 +13 -1
  32. package/dist/src/client.js +46 -6
  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/resources/sql.d.ts +5 -0
  39. package/dist/src/resources/sql.js +5 -4
  40. package/dist/src/skill.js +78 -19
  41. package/dist/src/utils/cleanup-ledger.d.ts +34 -1
  42. package/dist/src/utils/cleanup-ledger.js +118 -7
  43. package/dist/src/utils/dss-url.d.ts +6 -0
  44. package/dist/src/utils/dss-url.js +17 -0
  45. package/dist/src/utils/project-incarnation.d.ts +6 -0
  46. package/dist/src/utils/project-incarnation.js +27 -0
  47. package/dist/src/utils/stable-hash.d.ts +11 -0
  48. package/dist/src/utils/stable-hash.js +27 -0
  49. package/node_modules/@sinclair/typebox/build/cjs/compiler/compiler.js +26 -46
  50. package/node_modules/@sinclair/typebox/build/cjs/parser/runtime/guard.d.ts +1 -1
  51. package/node_modules/@sinclair/typebox/build/cjs/parser/runtime/token.d.ts +2 -2
  52. package/node_modules/@sinclair/typebox/build/cjs/syntax/mapping.d.ts +1 -1
  53. package/node_modules/@sinclair/typebox/build/cjs/system/evaluate.d.ts +5 -0
  54. package/node_modules/@sinclair/typebox/build/cjs/system/evaluate.js +11 -0
  55. package/node_modules/@sinclair/typebox/build/cjs/system/index.d.ts +1 -0
  56. package/node_modules/@sinclair/typebox/build/cjs/system/index.js +1 -0
  57. package/node_modules/@sinclair/typebox/build/cjs/type/object/object.d.ts +2 -2
  58. package/node_modules/@sinclair/typebox/build/cjs/type/object/object.js +18 -2
  59. package/node_modules/@sinclair/typebox/build/cjs/value/parse/parse.d.ts +1 -1
  60. package/node_modules/@sinclair/typebox/build/esm/compiler/compiler.mjs +27 -47
  61. package/node_modules/@sinclair/typebox/build/esm/parser/runtime/guard.d.mts +1 -1
  62. package/node_modules/@sinclair/typebox/build/esm/parser/runtime/token.d.mts +2 -2
  63. package/node_modules/@sinclair/typebox/build/esm/syntax/mapping.d.mts +1 -1
  64. package/node_modules/@sinclair/typebox/build/esm/system/evaluate.d.mts +5 -0
  65. package/node_modules/@sinclair/typebox/build/esm/system/evaluate.mjs +7 -0
  66. package/node_modules/@sinclair/typebox/build/esm/system/index.d.mts +1 -0
  67. package/node_modules/@sinclair/typebox/build/esm/system/index.mjs +1 -0
  68. package/node_modules/@sinclair/typebox/build/esm/type/composite/composite.mjs +2 -2
  69. package/node_modules/@sinclair/typebox/build/esm/type/const/const.mjs +3 -3
  70. package/node_modules/@sinclair/typebox/build/esm/type/instantiate/instantiate.mjs +2 -2
  71. package/node_modules/@sinclair/typebox/build/esm/type/mapped/mapped.mjs +3 -3
  72. package/node_modules/@sinclair/typebox/build/esm/type/module/compute.mjs +2 -2
  73. package/node_modules/@sinclair/typebox/build/esm/type/object/object.d.mts +2 -2
  74. package/node_modules/@sinclair/typebox/build/esm/type/object/object.mjs +18 -2
  75. package/node_modules/@sinclair/typebox/build/esm/type/omit/omit.mjs +3 -3
  76. package/node_modules/@sinclair/typebox/build/esm/type/partial/partial.mjs +3 -3
  77. package/node_modules/@sinclair/typebox/build/esm/type/pick/pick.mjs +3 -3
  78. package/node_modules/@sinclair/typebox/build/esm/type/record/record.mjs +3 -3
  79. package/node_modules/@sinclair/typebox/build/esm/type/required/required.mjs +3 -3
  80. package/node_modules/@sinclair/typebox/build/esm/type/type/json.mjs +2 -2
  81. package/node_modules/@sinclair/typebox/build/esm/value/delta/delta.mjs +4 -4
  82. package/node_modules/@sinclair/typebox/build/esm/value/parse/parse.d.mts +1 -1
  83. package/node_modules/@sinclair/typebox/package.json +1 -1
  84. package/package.json +9 -5
  85. package/packages/types/dist/index.d.ts +5 -0
  86. package/packages/types/dist/index.js +5 -0
@@ -0,0 +1,625 @@
1
+ import { DataikuError, } from "../../errors.js";
2
+ import { projectIncarnationHash, } from "../../utils/project-incarnation.js";
3
+ import { stableHash, stringField, } from "../coerce.js";
4
+ import { readIfExists, } from "../output.js";
5
+ import { UsageError, } from "../usage.js";
6
+ import { appPermissionsHash, appPermissionsVerificationError, } from "./app-permissions.js";
7
+ /**
8
+ * A DSS API key authenticates the public REST API only: the Application UI sits
9
+ * behind the interactive (often SSO) session, so no command in this SDK can
10
+ * observe it. Every app-release result therefore carries this gate explicitly
11
+ * instead of implying that a green API check means the UI works.
12
+ */
13
+ export const VISUAL_UI_GATE = {
14
+ gate: "visual-ui",
15
+ status: "required",
16
+ reason: "API key authenticates public REST only; use a pre-authenticated SSO browser session or dedicated UI test identity.",
17
+ requiredAuthentication: "pre-authenticated-sso-or-dedicated-ui-test-identity",
18
+ evidenceRequired: "Open the instance in the Dataiku Apps UI and exercise the affected tiles, forms, and actions to confirm the release behaves as intended.",
19
+ };
20
+ /**
21
+ * DSS answers the app-manifest probe with 400 ("neither an app template nor an
22
+ * app instance") for ordinary projects and 404 for missing ones. Both mean "not
23
+ * a verifiable app project", so they become a failed check instead of an error;
24
+ * every other failure (transient, permission) keeps propagating so real faults
25
+ * are never reported as a clean negative answer.
26
+ */
27
+ async function readAppProject(reader) {
28
+ try {
29
+ return await readIfExists(reader);
30
+ }
31
+ catch (error) {
32
+ if (error instanceof DataikuError && error.status === 400)
33
+ return undefined;
34
+ throw error;
35
+ }
36
+ }
37
+ function isPlainRecord(value) {
38
+ return typeof value === "object" && value !== null && !Array.isArray(value);
39
+ }
40
+ async function assertProjectIncarnation(client, projectKey, expectedHash) {
41
+ const details = await client.projects.get(projectKey);
42
+ const currentHash = projectIncarnationHash(projectKey, details);
43
+ if (currentHash === expectedHash)
44
+ return;
45
+ throw new UsageError(`Project ${projectKey} changed incarnation during successor verification.`, "validation_failed", "Refusing to read or write permissions on a replacement project. Re-run the successor workflow with a new target key.", {
46
+ projectKey,
47
+ expectedProjectIncarnationHash: expectedHash,
48
+ currentProjectIncarnationHash: currentHash ?? null,
49
+ });
50
+ }
51
+ /**
52
+ * The authoritative raw manifest version marker. DSS stores a free-form string,
53
+ * so only a non-whitespace string is a usable version: a missing, blank or
54
+ * non-string marker is reported as absent instead of being compared as if it
55
+ * named a release.
56
+ */
57
+ function rawVersion(manifest) {
58
+ if (!isPlainRecord(manifest))
59
+ return null;
60
+ const value = manifest["version"];
61
+ return typeof value === "string" && value.trim() !== "" ? value : null;
62
+ }
63
+ /**
64
+ * Project type of the snapshot's project. It comes from the manifest snapshot
65
+ * whenever DSS includes it and only otherwise costs a project-details request,
66
+ * so the ordinary verification path never re-reads the manifest.
67
+ */
68
+ async function snapshotAppType(client, snapshot, projectKey) {
69
+ const declared = snapshot["projectAppType"];
70
+ if (typeof declared === "string")
71
+ return declared;
72
+ const details = await readIfExists(() => client.projects.get(projectKey));
73
+ const value = details?.["projectAppType"];
74
+ return typeof value === "string" ? value : null;
75
+ }
76
+ function instanceCheck(check, expected, actual, ok, reason) {
77
+ return {
78
+ check,
79
+ status: ok ? "ok" : "failed",
80
+ expected,
81
+ actual,
82
+ ...(!ok && reason !== undefined ? { reason, } : {}),
83
+ };
84
+ }
85
+ /** Never echoes response bodies: only the taxonomy DataikuError already deems safe. */
86
+ export function safeErrorSummary(error) {
87
+ if (error instanceof DataikuError) {
88
+ return {
89
+ error: error.safeMessage,
90
+ errorStatus: error.status,
91
+ errorCategory: error.category,
92
+ retryable: error.retryable,
93
+ };
94
+ }
95
+ return { error: error instanceof Error ? error.message : String(error), };
96
+ }
97
+ /**
98
+ * Read-only API readiness of one app instance. Only checks that the public REST
99
+ * API can answer authoritatively are performed; the UI gate stays external.
100
+ *
101
+ * The instance manifest is read exactly once: its version, hash and reference
102
+ * validation all describe that one snapshot, so a concurrent manifest edit can
103
+ * never produce a report whose fields contradict each other.
104
+ */
105
+ export async function verifyAppInstance(client, appId, projectKey, expectVersion) {
106
+ const templateManifest = await client.applications.getAppManifest(appId);
107
+ const templateVersion = rawVersion(templateManifest);
108
+ const expectedVersion = expectVersion ?? templateVersion;
109
+ const instances = await client.applications.listInstances(appId);
110
+ const registered = instances.some((instance) => instance.projectKey === projectKey);
111
+ const raw = await readAppProject(() => client.applications.getInstanceManifest(projectKey));
112
+ const snapshot = isPlainRecord(raw) ? raw : undefined;
113
+ const projectAppType = snapshot ? await snapshotAppType(client, snapshot, projectKey) : null;
114
+ const instanceVersion = rawVersion(snapshot);
115
+ const referenceValidation = snapshot
116
+ ? await client.applications.validateAppManifest(snapshot, projectKey)
117
+ : undefined;
118
+ // A template without a usable version is a failed required check even when
119
+ // --expect-version was supplied: without it nothing pins what the instance
120
+ // was supposed to inherit, so an equal pair of strings proves nothing.
121
+ const checks = [
122
+ instanceCheck("project-type", "APP_INSTANCE", projectAppType, projectAppType === "APP_INSTANCE"),
123
+ instanceCheck("app-registration", appId, registered ? appId : null, registered),
124
+ instanceCheck("manifest-version", expectedVersion, instanceVersion, templateVersion !== null && expectedVersion !== null
125
+ && instanceVersion === expectedVersion, templateVersion === null ? "template-version-missing" : undefined),
126
+ instanceCheck("manifest-references", true, referenceValidation?.valid ?? null, referenceValidation?.valid === true),
127
+ ];
128
+ return {
129
+ appId,
130
+ projectKey,
131
+ projectAppType,
132
+ registered,
133
+ templateVersion,
134
+ expectedVersion,
135
+ expectedVersionSource: expectVersion !== undefined ? "expect-version" : "template-manifest",
136
+ instanceVersion,
137
+ instanceManifestHash: snapshot ? stableHash(snapshot) : null,
138
+ templateManifestHash: stableHash(templateManifest),
139
+ referenceValidation: referenceValidation
140
+ ? {
141
+ valid: referenceValidation.valid,
142
+ manifestHash: referenceValidation.manifestHash,
143
+ checks: referenceValidation.checks,
144
+ errors: referenceValidation.errors,
145
+ }
146
+ : null,
147
+ checks,
148
+ valid: checks.every((entry) => entry.status === "ok"),
149
+ };
150
+ }
151
+ const SUCCESSOR_REMEDIATION = "The successor project may exist: replay the recorded cleanup entry, which future-gates deletion when DSS supplied a future ID. Without a cleanup entry, inspect the creation future and wait for it to become terminal before deleting the requested successor project. The predecessor project was not modified.";
152
+ const CREATE_REJECTED_REMEDIATION = "DSS refused the creation request, so no successor project was created: fix the reported problem and retry. The predecessor project was not modified.";
153
+ /**
154
+ * True only when DSS definitively refused a write: a non-retryable 4xx with a
155
+ * classified client-side taxonomy. Transport failures (status 0), 5xx, retryable
156
+ * 4xx (408/425/429) and unclassified statuses leave the outcome unknown, so the
157
+ * effect of the request must be assumed possible.
158
+ */
159
+ export function isDefinitiveRejection(error) {
160
+ return error instanceof DataikuError && !error.retryable && error.category !== "unknown"
161
+ && error.status >= 400 && error.status < 500;
162
+ }
163
+ /**
164
+ * Successor identity as reported by the creation future's own result.
165
+ *
166
+ * DSS names the project it created in `targetProjectKey`, and live instances of
167
+ * this future are also observed carrying `projectKey` for the *source template*,
168
+ * so the target-specific field always wins and `projectKey` speaks for the
169
+ * target only when no usable `targetProjectKey` is present. A missing, blank or
170
+ * non-string value is no identity claim at all, exactly as a blank manifest
171
+ * version names no release.
172
+ */
173
+ export function futureTargetIdentity(result) {
174
+ if (!isPlainRecord(result))
175
+ return null;
176
+ for (const field of ["targetProjectKey", "projectKey",]) {
177
+ const value = result[field];
178
+ if (typeof value === "string" && value.trim() !== "") {
179
+ return { projectKey: value.trim(), field, };
180
+ }
181
+ }
182
+ return null;
183
+ }
184
+ /**
185
+ * Creation cleanup is safe only when the target was proven absent before POST.
186
+ * A hidden 403 is not absence: project listing can prove a collision, but it
187
+ * cannot prove that an inaccessible project does not exist.
188
+ */
189
+ export async function requireAbsentProjectTarget(client, targetProjectKey, targetFlag) {
190
+ try {
191
+ const existing = await readIfExists(() => client.projects.get(targetProjectKey));
192
+ if (existing === undefined)
193
+ return;
194
+ }
195
+ catch (error) {
196
+ if (!(error instanceof DataikuError) || error.status !== 403)
197
+ throw error;
198
+ const accessible = await client.projects.list();
199
+ if (!accessible.some((project) => project.projectKey === targetProjectKey)) {
200
+ throw new UsageError(`Could not confirm target project ${targetProjectKey} is absent.`, "validation_failed", "Grant project-list/details visibility or choose a target key whose absence DSS can confirm; creation never risks cleanup against an unproven target.", { targetProjectKey, targetFlag, targetProbe: "forbidden-and-not-listable", });
201
+ }
202
+ }
203
+ throw new UsageError(`Target project ${targetProjectKey} already exists.`, "validation_failed", `Choose an unused ${targetFlag}: app instance creation never writes into an existing project.`, { targetProjectKey, targetFlag, });
204
+ }
205
+ /**
206
+ * Everything that can still refuse the request runs here, before the single
207
+ * POST. Nothing in this function mutates DSS.
208
+ */
209
+ async function preflightSuccessor(client, request, usage) {
210
+ const { appId, from, to, } = request;
211
+ if (from === to) {
212
+ throw new UsageError("--from and --to must be different project keys: a successor instance is created alongside its predecessor.", "validation_failed", `Usage: ${usage}`, { sourceProjectKey: from, targetProjectKey: to, });
213
+ }
214
+ const instances = await client.applications.listInstances(appId);
215
+ if (!instances.some((instance) => instance.projectKey === from)) {
216
+ throw new UsageError(`Project ${from} is not registered as an instance of app ${appId}.`, "validation_failed", "Check `dss app instances <appId>` and pass the predecessor instance project key to --from.", {
217
+ appId,
218
+ sourceProjectKey: from,
219
+ registeredInstances: instances.map((instance) => instance.projectKey ?? null),
220
+ });
221
+ }
222
+ const source = await readAppProject(() => client.applications.getManifestVersion(from));
223
+ if (!source) {
224
+ throw new UsageError(`Project ${from} could not be read as an app project.`, "validation_failed", "--from must name an existing classic Dataiku App instance project (APP_INSTANCE).", { sourceProjectKey: from, });
225
+ }
226
+ if (source.projectAppType !== "APP_INSTANCE") {
227
+ throw new UsageError(`Project ${from} is a ${source.projectAppType ?? "non-app"} project, not a classic Dataiku App instance.`, "validation_failed", "--from must name an APP_INSTANCE project; app templates are versioned with `dss app set-manifest-version`.", { sourceProjectKey: from, projectAppType: source.projectAppType ?? null, });
228
+ }
229
+ await requireAbsentProjectTarget(client, to, "--to");
230
+ const templateManifest = await client.applications.getAppManifest(appId);
231
+ const templateVersion = rawVersion(templateManifest);
232
+ if (templateVersion === null) {
233
+ throw new UsageError(`App template ${appId} has no manifest version, so a successor instance cannot be verified against it.`, "validation_failed", "Set the template version first with `dss app set-manifest-version --manifest-version V --project-key <templateProjectKey>`.", { appId, });
234
+ }
235
+ const sourcePermissions = request.copyPermissions
236
+ ? await client.projects.getPermissions(from)
237
+ : undefined;
238
+ return {
239
+ sourceProjectAppType: source.projectAppType,
240
+ sourceVersion: source.version,
241
+ sourceManifestHash: source.manifestHash,
242
+ targetProjectName: request.name ?? to,
243
+ targetExists: false,
244
+ templateManifestHash: stableHash(templateManifest),
245
+ templateVersion,
246
+ ...(sourcePermissions
247
+ ? {
248
+ sourcePermissions,
249
+ sourcePermissionsHash: appPermissionsHash(sourcePermissions),
250
+ }
251
+ : {}),
252
+ };
253
+ }
254
+ /**
255
+ * Create a new app instance from the current template version alongside an
256
+ * existing instance. Classic app instances have no public upgrade endpoint, so
257
+ * the predecessor is never touched: it stays available until it is separately
258
+ * and deliberately retired.
259
+ *
260
+ * After the creation POST this function never throws. A post-POST failure
261
+ * returns a failed-wait result carrying the target project key, so the CLI still
262
+ * exits 4 and the cleanup ledger still records an addressable successor.
263
+ */
264
+ export async function createSuccessorInstance(client, request, usage) {
265
+ const { appId, from, to, } = request;
266
+ const preflight = await preflightSuccessor(client, request, usage);
267
+ const source = {
268
+ projectKey: from,
269
+ projectAppType: preflight.sourceProjectAppType,
270
+ version: preflight.sourceVersion,
271
+ manifestHash: preflight.sourceManifestHash,
272
+ ...(preflight.sourcePermissionsHash
273
+ ? { permissionsHash: preflight.sourcePermissionsHash, }
274
+ : {}),
275
+ };
276
+ const shared = {
277
+ appId,
278
+ source,
279
+ templateVersion: preflight.templateVersion,
280
+ templateManifestHash: preflight.templateManifestHash,
281
+ additive: true,
282
+ sourcePreserved: true,
283
+ uiPublicationVerified: false,
284
+ requiredExternalGates: [VISUAL_UI_GATE,],
285
+ targetPreflight: "confirmed-absent",
286
+ };
287
+ if (request.dryRun) {
288
+ return {
289
+ dryRun: true,
290
+ action: "create-successor-instance",
291
+ ...shared,
292
+ target: {
293
+ projectKey: to,
294
+ name: preflight.targetProjectName,
295
+ exists: false,
296
+ },
297
+ copyPermissions: request.copyPermissions,
298
+ preflight: "passed",
299
+ };
300
+ }
301
+ let created;
302
+ try {
303
+ created = await client.applications.createInstance(appId, {
304
+ targetProjectKey: to,
305
+ targetProjectName: preflight.targetProjectName,
306
+ });
307
+ }
308
+ catch (error) {
309
+ const rejected = isDefinitiveRejection(error);
310
+ return {
311
+ ...shared,
312
+ projectKey: to,
313
+ target: { projectKey: to, name: preflight.targetProjectName, },
314
+ success: false,
315
+ state: "CREATE_FAILED",
316
+ elapsedMs: 0,
317
+ pollCount: 0,
318
+ stage: "create",
319
+ cleanupEligible: !rejected,
320
+ remediation: rejected ? CREATE_REJECTED_REMEDIATION : SUCCESSOR_REMEDIATION,
321
+ ...safeErrorSummary(error),
322
+ };
323
+ }
324
+ // DSS names the target project in `targetProjectKey` first; `projectKey` is
325
+ // only consulted when the target-specific field is absent, because live
326
+ // responses also echo `projectKey` for the *source* template.
327
+ const instance = isPlainRecord(created) ? created : undefined;
328
+ const jobId = instance ? stringField(instance, ["jobId",]) : undefined;
329
+ const inlineState = instance?.hasResult === true ? instance : undefined;
330
+ const echoedTarget = instance
331
+ ? stringField(instance, ["targetProjectKey", "projectKey",])?.trim()
332
+ : undefined;
333
+ if (echoedTarget !== undefined && echoedTarget !== to) {
334
+ return {
335
+ ...shared,
336
+ projectKey: to,
337
+ target: { projectKey: to, name: preflight.targetProjectName, },
338
+ success: false,
339
+ state: "VERIFICATION_FAILED",
340
+ elapsedMs: 0,
341
+ pollCount: 0,
342
+ stage: "create",
343
+ ...(instance ? { instance, } : {}),
344
+ ...(jobId !== undefined ? { futureId: jobId, jobId, } : {}),
345
+ cleanupEligible: true,
346
+ error: "DSS named a different target project in the creation response.",
347
+ expected: { projectKey: to, },
348
+ actual: { projectKey: echoedTarget, },
349
+ remediation: SUCCESSOR_REMEDIATION,
350
+ };
351
+ }
352
+ const base = {
353
+ ...shared,
354
+ projectKey: to,
355
+ target: { projectKey: to, name: preflight.targetProjectName, },
356
+ ...(instance ? { instance, } : {}),
357
+ };
358
+ let waited;
359
+ if (inlineState) {
360
+ // `hasResult` on the creation response is the future's own verdict: the
361
+ // answer arrived with the POST, so polling is skipped entirely and the
362
+ // verification below runs on the delivered result.
363
+ waited = {
364
+ ...(jobId !== undefined ? { futureId: jobId, jobId, } : {}),
365
+ state: "DONE",
366
+ elapsedMs: 0,
367
+ pollCount: 0,
368
+ success: true,
369
+ hasResult: true,
370
+ ...(isPlainRecord(inlineState.result) ? { result: inlineState.result, } : {}),
371
+ };
372
+ }
373
+ else if (!jobId) {
374
+ return {
375
+ ...base,
376
+ ...(instance ? {} : {
377
+ responseKind: created === undefined ? "empty" : created === null
378
+ ? "null"
379
+ : Array.isArray(created)
380
+ ? "array"
381
+ : "scalar",
382
+ }),
383
+ success: false,
384
+ state: "UNTRACKABLE",
385
+ elapsedMs: 0,
386
+ pollCount: 0,
387
+ stage: "future-wait",
388
+ cleanupEligible: true,
389
+ error: "DSS returned no instance-creation future ID, so the successor instance cannot be verified.",
390
+ remediation: SUCCESSOR_REMEDIATION,
391
+ };
392
+ }
393
+ else {
394
+ const startedAt = Date.now();
395
+ try {
396
+ waited = await client.futures.wait(jobId, {
397
+ pollIntervalMs: request.pollIntervalMs,
398
+ timeoutMs: request.timeoutMs,
399
+ });
400
+ }
401
+ catch (error) {
402
+ return {
403
+ ...base,
404
+ jobId,
405
+ success: false,
406
+ state: "WAIT_FAILED",
407
+ elapsedMs: Date.now() - startedAt,
408
+ pollCount: 0,
409
+ stage: "future-wait",
410
+ cleanupEligible: true,
411
+ remediation: SUCCESSOR_REMEDIATION,
412
+ ...safeErrorSummary(error),
413
+ };
414
+ }
415
+ }
416
+ // `projectKey` is re-pinned after the future spread: nothing DSS answers can
417
+ // rename the cleanup target away from the requested successor key.
418
+ const waitedBase = { ...base, ...waited, jobId, projectKey: to, };
419
+ // Proof that the terminal creation future named this exact target. It is set
420
+ // once, below, and then carried by every later result: a creation that DSS
421
+ // already settled on this project stays settled even when verification or the
422
+ // permission copy afterwards fails, so the cleanup ledger needs no second
423
+ // future gate to delete the successor.
424
+ let targetEvidence = {};
425
+ const failure = (state, stage, extra) => ({
426
+ ...waitedBase,
427
+ ...extra,
428
+ success: false,
429
+ state,
430
+ stage,
431
+ cleanupEligible: true,
432
+ remediation: SUCCESSOR_REMEDIATION,
433
+ ...targetEvidence,
434
+ });
435
+ // The future's result is the last place DSS can name the project it actually
436
+ // created. Extract that identity even when the terminal future failed: later
437
+ // cleanup should not depend on an ephemeral future once DSS explicitly named
438
+ // this target, while a disagreement must never redirect cleanup.
439
+ const futureTarget = futureTargetIdentity(waited.result);
440
+ if (futureTarget !== null && futureTarget.projectKey !== to) {
441
+ return failure("VERIFICATION_FAILED", "future-target", {
442
+ futureTargetMismatch: true,
443
+ futureTargetField: futureTarget.field,
444
+ expected: { projectKey: to, },
445
+ actual: { projectKey: futureTarget.projectKey, },
446
+ error: "The instance-creation future reported a different successor project than the one requested.",
447
+ });
448
+ }
449
+ if (futureTarget?.projectKey === to)
450
+ targetEvidence = { futureTargetVerified: true, };
451
+ if (!waited.success) {
452
+ return failure(waited.state, "future-wait", {
453
+ error: "The instance-creation future did not complete successfully.",
454
+ });
455
+ }
456
+ try {
457
+ const details = await client.projects.get(to);
458
+ const incarnation = projectIncarnationHash(to, details);
459
+ if (!incarnation) {
460
+ return failure("INCARNATION_UNVERIFIED", "project-incarnation", {
461
+ error: "DSS did not return creationTag identity for the successor project. Refusing cleanup without project-incarnation binding.",
462
+ });
463
+ }
464
+ targetEvidence = { ...targetEvidence, projectIncarnationHash: incarnation, };
465
+ }
466
+ catch (error) {
467
+ return failure("INCARNATION_UNVERIFIED", "project-incarnation", safeErrorSummary(error));
468
+ }
469
+ const boundTargetIncarnation = targetEvidence.projectIncarnationHash;
470
+ if (!boundTargetIncarnation) {
471
+ return failure("INCARNATION_UNVERIFIED", "project-incarnation", {
472
+ error: "The successor project incarnation could not be retained for verification.",
473
+ });
474
+ }
475
+ let verification;
476
+ try {
477
+ verification = await verifyAppInstance(client, appId, to, preflight.templateVersion);
478
+ await assertProjectIncarnation(client, to, boundTargetIncarnation);
479
+ }
480
+ catch (error) {
481
+ return failure("VERIFICATION_FAILED", "verification", safeErrorSummary(error));
482
+ }
483
+ const verified = {
484
+ instanceVersion: verification.instanceVersion,
485
+ instanceManifestHash: verification.instanceManifestHash,
486
+ checks: verification.checks,
487
+ referenceValidation: verification.referenceValidation,
488
+ };
489
+ // The template was pinned during preflight and verification re-reads it. A
490
+ // changed version proves the successor came from a different release; an
491
+ // unchanged version over a changed manifest hash proves the release's own
492
+ // content moved underneath it. Both signals are reported independently so a
493
+ // content-only edit is never dressed up as a version bump, nor the reverse.
494
+ const templateVersionDrift = verification.templateVersion !== preflight.templateVersion;
495
+ const templateManifestDrift = verification.templateManifestHash !== preflight.templateManifestHash;
496
+ if (templateVersionDrift || templateManifestDrift) {
497
+ return failure("VERIFICATION_FAILED", "verification", {
498
+ ...verified,
499
+ templateVersionDrift,
500
+ templateManifestDrift,
501
+ expectedTemplateVersion: preflight.templateVersion,
502
+ actualTemplateVersion: verification.templateVersion,
503
+ expectedTemplateManifestHash: preflight.templateManifestHash,
504
+ actualTemplateManifestHash: verification.templateManifestHash,
505
+ error: templateVersionDrift
506
+ ? "The template manifest version changed after the successor creation request was accepted."
507
+ : "The template manifest content changed after the successor creation request was accepted, although its version marker did not.",
508
+ });
509
+ }
510
+ if (!verification.valid) {
511
+ return failure("VERIFICATION_FAILED", "verification", {
512
+ ...verified,
513
+ error: "The successor instance did not pass every required API check.",
514
+ });
515
+ }
516
+ const desiredHash = preflight.sourcePermissionsHash;
517
+ const sourcePermissions = preflight.sourcePermissions;
518
+ const copyFailure = (state, summary = {}) => ({
519
+ ...verified,
520
+ permissions: {
521
+ requested: true,
522
+ // Never overclaim: only a verified copy reports true; an unresolved
523
+ // PUT outcome or failed verification read reports null, and every
524
+ // stage that can prove no copy happened reports false.
525
+ copied: state === "copied"
526
+ ? true
527
+ : state === "unknown" || state === "completed-unverified"
528
+ ? null
529
+ : false,
530
+ state,
531
+ sourceHash: desiredHash,
532
+ verified: false,
533
+ ...summary,
534
+ },
535
+ });
536
+ let permissions = {
537
+ requested: false,
538
+ copied: false,
539
+ state: "not-requested",
540
+ verified: false,
541
+ };
542
+ if (sourcePermissions && desiredHash) {
543
+ // The stage variable separates failures that happened before the write
544
+ // (the copy was definitely never started) from a definitive PUT rejection,
545
+ // an indeterminate PUT outcome, and a resolved PUT whose verification
546
+ // read failed. "recheck" is the source-ACL drift guard: the predecessor
547
+ // snapshot is hashed again immediately before the target write.
548
+ let stage = "before-read";
549
+ try {
550
+ const before = await client.projects.getPermissions(to);
551
+ await assertProjectIncarnation(client, to, boundTargetIncarnation);
552
+ const beforeHash = appPermissionsHash(before);
553
+ // The predecessor ACL may have changed since preflight. Re-read it
554
+ // even when the target already has the old hash: "unchanged" is only
555
+ // true when source and target still agree at this decision point.
556
+ stage = "recheck";
557
+ const sourceNow = await client.projects.getPermissions(from);
558
+ const sourceNowHash = appPermissionsHash(sourceNow);
559
+ if (sourceNowHash !== desiredHash) {
560
+ return failure("PERMISSIONS_FAILED", "permissions", {
561
+ ...copyFailure("not-attempted", {
562
+ beforeHash,
563
+ sourceDrift: true,
564
+ sourceHashNow: sourceNowHash,
565
+ }),
566
+ error: "The predecessor ACL changed after preflight, so the stale snapshot was not copied to the successor.",
567
+ });
568
+ }
569
+ await assertProjectIncarnation(client, to, boundTargetIncarnation);
570
+ if (beforeHash === desiredHash) {
571
+ permissions = {
572
+ requested: true,
573
+ copied: false,
574
+ state: "unchanged",
575
+ reason: "unchanged",
576
+ sourceHash: desiredHash,
577
+ beforeHash,
578
+ verifiedHash: beforeHash,
579
+ verified: true,
580
+ };
581
+ }
582
+ else {
583
+ stage = "put";
584
+ await client.projects.setPermissions(to, sourceNow);
585
+ stage = "after-read";
586
+ const after = await client.projects.getPermissions(to);
587
+ await assertProjectIncarnation(client, to, boundTargetIncarnation);
588
+ const verifiedHash = appPermissionsHash(after);
589
+ if (verifiedHash !== desiredHash) {
590
+ return failure("PERMISSIONS_FAILED", "permissions", {
591
+ ...copyFailure("rejected", {
592
+ beforeHash,
593
+ verifiedHash,
594
+ reason: "verified-hash-mismatch",
595
+ }),
596
+ error: appPermissionsVerificationError(to, {
597
+ beforeHash,
598
+ desiredHash,
599
+ verifiedHash,
600
+ }).message,
601
+ });
602
+ }
603
+ permissions = {
604
+ requested: true,
605
+ copied: true,
606
+ state: "copied",
607
+ sourceHash: desiredHash,
608
+ beforeHash,
609
+ verifiedHash,
610
+ verified: true,
611
+ };
612
+ }
613
+ }
614
+ catch (error) {
615
+ return failure("PERMISSIONS_FAILED", "permissions", copyFailure(stage === "before-read" || stage === "recheck"
616
+ ? "not-attempted"
617
+ : stage === "put" && isDefinitiveRejection(error)
618
+ ? "rejected"
619
+ : stage === "put"
620
+ ? "unknown"
621
+ : "completed-unverified", safeErrorSummary(error)));
622
+ }
623
+ }
624
+ return { ...waitedBase, ...verified, permissions, ...targetEvidence, };
625
+ }